From fbf900c7461f1a74ef94b1c80669ae06f1e9a62c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 11:12:15 +0100 Subject: [PATCH] refactor: move plugin state consumers to sqlite Summary: - add plugin-state runtime SDK subpaths backed by the existing sidecar DB - migrate Discord model-picker preferences and Feishu dedup state to plugin-state keyed stores - wire doctor legacy-state migration imports, including TTL preservation, for existing plugin JSON state Verification: - pnpm plugin-sdk:api:check - focused plugin-state, doctor, Discord, Feishu, and package-boundary Vitest suites - git diff --check origin/main...HEAD - env -u OPENCLAW_TESTBOX pnpm check:changed - autoreview --mode branch --base origin/main - GitHub Actions PR checks green on 1025c2b570b6d672427f72cc149db53b68a7c93e --- docs/channels/feishu.md | 2 +- docs/plugins/sdk-subpaths.md | 1 + .../discord/legacy-state-migrations-api.ts | 1 + extensions/discord/package.json | 3 + extensions/discord/setup-entry.test.ts | 10 + extensions/discord/setup-entry.ts | 7 + ...odel-picker-preferences-migrations.test.ts | 66 +++++ .../model-picker-preferences-migrations.ts | 145 ++++++++++ .../monitor/model-picker-preferences.test.ts | 157 +++++++++- .../src/monitor/model-picker-preferences.ts | 258 ++++++++++++----- .../feishu/legacy-state-migrations-api.ts | 1 + extensions/feishu/npm-shrinkwrap.json | 2 +- extensions/feishu/package.json | 9 +- extensions/feishu/runtime-api.ts | 1 - extensions/feishu/setup-entry.test.ts | 2 + extensions/feishu/setup-entry.ts | 7 + .../feishu/src/dedup-migrations.test.ts | 89 ++++++ extensions/feishu/src/dedup-migrations.ts | 102 +++++++ extensions/feishu/src/dedup-runtime-api.ts | 1 - extensions/feishu/src/dedup.test.ts | 94 ++++++ extensions/feishu/src/dedup.ts | 272 ++++++++++++++++-- .../tsconfig.package-boundary.paths.json | 3 + extensions/xai/tsconfig.json | 3 + package.json | 6 + .../official-external-channel-catalog.json | 2 +- scripts/lib/plugin-sdk-entrypoints.json | 2 + ...lugin-sdk-private-local-only-subpaths.json | 1 + src/channels/plugins/types.core.ts | 4 +- src/commands/doctor-state-migrations.test.ts | 11 +- src/infra/state-migrations.ts | 6 +- src/plugin-sdk/plugin-state-runtime.ts | 6 + src/plugin-sdk/plugin-state-test-runtime.ts | 5 + .../test-helpers/plugin-runtime-mock.ts | 3 + .../plugin-state-store.runtime.test.ts | 13 + src/plugin-state/plugin-state-store.sqlite.ts | 186 +++++++----- src/plugin-state/plugin-state-store.test.ts | 36 ++- src/plugin-state/plugin-state-store.ts | 173 +++++++++-- src/plugin-state/plugin-state-store.types.ts | 11 + src/plugins/registry.ts | 27 +- src/plugins/runtime/index.ts | 3 + src/plugins/runtime/types-core.ts | 3 + 41 files changed, 1492 insertions(+), 242 deletions(-) create mode 100644 extensions/discord/legacy-state-migrations-api.ts create mode 100644 extensions/discord/setup-entry.test.ts create mode 100644 extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts create mode 100644 extensions/discord/src/monitor/model-picker-preferences-migrations.ts create mode 100644 extensions/feishu/legacy-state-migrations-api.ts create mode 100644 extensions/feishu/src/dedup-migrations.test.ts create mode 100644 extensions/feishu/src/dedup-migrations.ts delete mode 100644 extensions/feishu/src/dedup-runtime-api.ts create mode 100644 extensions/feishu/src/dedup.test.ts create mode 100644 src/plugin-sdk/plugin-state-runtime.ts create mode 100644 src/plugin-sdk/plugin-state-test-runtime.ts diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 0b97818ec6ec..4642dc262ad8 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -15,7 +15,7 @@ Feishu/Lark is an all-in-one collaboration platform where teams chat, share docu ## Quick start -Requires OpenClaw 2026.4.25 or above. Run `openclaw --version` to check. Upgrade with `openclaw update`. +Requires OpenClaw 2026.5.29 or above. Run `openclaw --version` to check. Upgrade with `openclaw update`. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 6558560a8b75..f09f8cd7e15c 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -237,6 +237,7 @@ and pairing-path families. | `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers | | `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers | | `plugin-sdk/state-paths` | State/OAuth dir path helpers | + | `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types | | `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` | | `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers | | `plugin-sdk/target-resolver-runtime` | Shared target resolver helpers | diff --git a/extensions/discord/legacy-state-migrations-api.ts b/extensions/discord/legacy-state-migrations-api.ts new file mode 100644 index 000000000000..570784700eae --- /dev/null +++ b/extensions/discord/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectDiscordLegacyStateMigrations } from "./src/monitor/model-picker-preferences-migrations.js"; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 58649017d5e3..a9539f18c759 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -32,6 +32,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true + }, "channel": { "id": "discord", "label": "Discord", diff --git a/extensions/discord/setup-entry.test.ts b/extensions/discord/setup-entry.test.ts new file mode 100644 index 000000000000..c9bcef525afe --- /dev/null +++ b/extensions/discord/setup-entry.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import setupEntry from "./setup-entry.js"; + +describe("discord setup entry", () => { + it("exposes legacy state migration detector through setup entry metadata", () => { + expect(setupEntry.kind).toBe("bundled-channel-setup-entry"); + expect(setupEntry.features).toEqual({ legacyStateMigrations: true }); + expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function"); + }); +}); diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index aa5c385f21d1..be9480f4d7d1 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -2,8 +2,15 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, + features: { + legacyStateMigrations: true, + }, plugin: { specifier: "./setup-plugin-api.js", exportName: "discordSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectDiscordLegacyStateMigrations", + }, }); diff --git a/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts b/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts new file mode 100644 index 000000000000..83bd0d2d637c --- /dev/null +++ b/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { detectDiscordLegacyStateMigrations } from "./model-picker-preferences-migrations.js"; + +const tempDirs: string[] = []; + +async function makeStateDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-model-picker-migration-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("Discord model picker preference migration", () => { + it("plans legacy JSON import into plugin state", async () => { + const stateDir = await makeStateDir(); + const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + entries: { + "discord:default:dm:user:123": { + recent: ["OpenAI/gpt-5", "bad", "openai/gpt-5"], + updatedAt: "2026-05-29T00:00:00.000Z", + }, + }, + }), + ); + + const plans = await Promise.resolve( + detectDiscordLegacyStateMigrations({ + cfg: {}, + env: {}, + oauthDir: path.join(stateDir, "credentials"), + stateDir, + }), + ); + + if (!plans) { + throw new Error("expected migration plans"); + } + expect(plans).toHaveLength(1); + const plan = plans[0]; + expect(plan?.kind).toBe("plugin-state-import"); + if (plan?.kind !== "plugin-state-import") { + throw new Error("expected plugin-state import plan"); + } + expect(plan.pluginId).toBe("discord"); + expect(plan.namespace).toBe("model-picker-preferences"); + const entries = await plan.readEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.key).toMatch(/^v1:[0-9a-f]{32}:[0-9a-f]{24}$/u); + expect(entries[0]?.value).toEqual({ + scopeKey: "discord:default:dm:user:123", + modelRef: "openai/gpt-5", + updatedAt: "2026-05-29T00:00:00.001Z", + }); + }); +}); diff --git a/extensions/discord/src/monitor/model-picker-preferences-migrations.ts b/extensions/discord/src/monitor/model-picker-preferences-migrations.ts new file mode 100644 index 000000000000..6db44f01f0d6 --- /dev/null +++ b/extensions/discord/src/monitor/model-picker-preferences-migrations.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { BundledChannelLegacyStateMigrationDetector } from "openclaw/plugin-sdk/channel-entry-contract"; +import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; + +const PREFERENCE_MAX_ENTRIES = 2_000; +const MAX_PLUGIN_STATE_KEY_BYTES = 512; +const textEncoder = new TextEncoder(); + +type LegacyModelPickerPreferencesEntry = { + recent?: unknown; + updatedAt?: unknown; +}; + +type LegacyModelPickerPreferencesStore = { + entries?: unknown; +}; + +function fileExists(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function readLegacyStore(filePath: string): LegacyModelPickerPreferencesStore | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" + ? (parsed as LegacyModelPickerPreferencesStore) + : null; + } catch { + return null; + } +} + +function normalizeLegacyPreferenceKey(key: string): string | undefined { + const trimmed = key.trim(); + if (!trimmed || textEncoder.encode(trimmed).length > MAX_PLUGIN_STATE_KEY_BYTES) { + return undefined; + } + return trimmed; +} + +function normalizeModelRef(raw?: string): string | null { + const value = raw?.trim(); + if (!value) { + return null; + } + const slashIndex = value.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= value.length - 1) { + return null; + } + const provider = normalizeProviderId(value.slice(0, slashIndex)); + const model = value.slice(slashIndex + 1).trim(); + return provider && model ? `${provider}/${model}` : null; +} + +function sanitizeRecentModels(models: unknown, limit: number): string[] { + const deduped: string[] = []; + const seen = new Set(); + if (!Array.isArray(models)) { + return deduped; + } + for (const item of models) { + const normalized = normalizeModelRef(typeof item === "string" ? item : undefined); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + deduped.push(normalized); + if (deduped.length >= limit) { + break; + } + } + return deduped; +} + +function hashSegment(value: string, length: number): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, length); +} + +function buildPreferenceModelKey(scopeKey: string, modelRef: string): string { + return `v1:${hashSegment(scopeKey, 32)}:${hashSegment(modelRef, 24)}`; +} + +function timestampMs(value: unknown): number { + const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : 0; +} + +function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: number): string { + return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString(); +} + +export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({ + stateDir, +}) => { + const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json"); + if (!fileExists(sourcePath)) { + return []; + } + return [ + { + kind: "plugin-state-import", + label: "Discord model picker preferences", + sourcePath, + targetPath: "plugin state:model-picker-preferences", + pluginId: "discord", + namespace: "model-picker-preferences", + maxEntries: PREFERENCE_MAX_ENTRIES, + scopeKey: "", + cleanupSource: "rename", + readEntries: () => { + const store = readLegacyStore(sourcePath); + if (!store || !store.entries || typeof store.entries !== "object") { + return []; + } + const out: Array<{ key: string; value: unknown }> = []; + for (const [rawKey, rawEntry] of Object.entries( + store.entries as Record, + )) { + const scopeKey = normalizeLegacyPreferenceKey(rawKey); + if (!scopeKey || !rawEntry || typeof rawEntry !== "object") { + continue; + } + const recent = sanitizeRecentModels(rawEntry.recent, 10); + for (const [index, modelRef] of recent.entries()) { + out.push({ + key: buildPreferenceModelKey(scopeKey, modelRef), + value: { + scopeKey, + modelRef, + updatedAt: legacyUpdatedAtForIndex(rawEntry.updatedAt, index, recent.length), + }, + }); + } + } + return out; + }, + }, + ]; +}; diff --git a/extensions/discord/src/monitor/model-picker-preferences.test.ts b/extensions/discord/src/monitor/model-picker-preferences.test.ts index 14703cf571b5..716a83d1a787 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.test.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.test.ts @@ -1,8 +1,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { afterEach, describe, expect, it } from "vitest"; +import { setDiscordRuntime, type DiscordRuntime } from "../runtime.js"; +import { + buildDiscordModelPickerPreferenceKey, readDiscordModelPickerRecentModels, recordDiscordModelPickerRecentModel, } from "./model-picker-preferences.js"; @@ -12,10 +19,21 @@ const tempDirs: string[] = []; async function createStateEnv(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-model-picker-")); tempDirs.push(dir); - return { ...process.env, OPENCLAW_STATE_DIR: dir }; + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + setDiscordRuntime({ + state: { + openKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateKeyedStoreForTests("discord", { + ...options, + env: options.env ?? env, + }), + }, + } as unknown as DiscordRuntime); + return env; } afterEach(async () => { + resetPluginStateStoreForTests(); await Promise.all( tempDirs.splice(0).map(async (dir) => { await fs.rm(dir, { recursive: true, force: true }); @@ -51,12 +69,40 @@ describe("discord model picker preferences", () => { expect(recent).toEqual(["openai/gpt-4.1"]); }); - it("falls back to an empty store when the file is corrupt", async () => { + it("prunes older stored models beyond the recent limit", async () => { const env = await createStateEnv(); - const stateDir = env.OPENCLAW_STATE_DIR as string; - const filePath = path.join(stateDir, "discord", "model-picker-preferences.json"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, "{not-json", "utf-8"); + const scope = { userId: "limited-user" }; + for (const modelRef of [ + "openai/model-a", + "openai/model-b", + "openai/model-c", + "openai/model-d", + ]) { + await recordDiscordModelPickerRecentModel({ env, scope, modelRef, limit: 2 }); + } + + await expect(readDiscordModelPickerRecentModels({ env, scope, limit: 10 })).resolves.toEqual([ + "openai/model-d", + "openai/model-c", + ]); + const store = createPluginStateKeyedStoreForTests("discord", { + namespace: "model-picker-preferences", + maxEntries: 2_000, + env, + }); + expect(await store.entries()).toHaveLength(2); + }); + + it("falls back to empty recents when stored state is malformed", async () => { + const env = await createStateEnv(); + const key = buildDiscordModelPickerPreferenceKey({ userId: "789" }); + expect(key).toBeTruthy(); + const store = createPluginStateKeyedStoreForTests("discord", { + namespace: "model-picker-preferences", + maxEntries: 2_000, + env, + }); + await store.register(key as string, "not-an-entry"); const recent = await readDiscordModelPickerRecentModels({ env, @@ -64,4 +110,101 @@ describe("discord model picker preferences", () => { }); expect(recent).toStrictEqual([]); }); + + it("treats plugin-state failures as optional preference misses", async () => { + const env = await createStateEnv(); + const scope = { userId: "state-failure-user" }; + setDiscordRuntime({ + state: { + openKeyedStore: () => { + throw new Error("state unavailable"); + }, + }, + } as unknown as DiscordRuntime); + + await expect(readDiscordModelPickerRecentModels({ env, scope })).resolves.toEqual([]); + await expect( + recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4.1" }), + ).resolves.toBeUndefined(); + }); + + it("imports legacy JSON preferences into plugin state", async () => { + const env = await createStateEnv(); + const scope = { accountId: "main", guildId: "guild-1", userId: "user-1" }; + const key = buildDiscordModelPickerPreferenceKey(scope); + expect(key).toBeTruthy(); + const legacyPath = path.join( + env.OPENCLAW_STATE_DIR as string, + "discord", + "model-picker-preferences.json", + ); + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile( + legacyPath, + JSON.stringify({ + version: 1, + entries: { + [key as string]: { + recent: ["openai/gpt-4.1", "bad-model", "openai/gpt-4o"], + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }), + "utf8", + ); + + const recent = await readDiscordModelPickerRecentModels({ env, scope }); + expect(recent).toEqual(["openai/gpt-4.1", "openai/gpt-4o"]); + + await fs.rm(legacyPath, { force: true }); + expect(await readDiscordModelPickerRecentModels({ env, scope })).toEqual([ + "openai/gpt-4.1", + "openai/gpt-4o", + ]); + }); + + it("skips malformed legacy JSON entries during import", async () => { + const env = await createStateEnv(); + const scope = { userId: "valid-legacy-user" }; + const key = buildDiscordModelPickerPreferenceKey(scope); + expect(key).toBeTruthy(); + const legacyPath = path.join( + env.OPENCLAW_STATE_DIR as string, + "discord", + "model-picker-preferences.json", + ); + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile( + legacyPath, + JSON.stringify({ + version: 1, + entries: { + "": { recent: ["openai/bad-empty"], updatedAt: "bad" }, + ["x".repeat(600)]: { recent: ["openai/bad-long"], updatedAt: "bad" }, + [key as string]: { + recent: ["not-a-model", "openai/gpt-4.1"], + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }), + "utf8", + ); + + await expect(readDiscordModelPickerRecentModels({ env, scope })).resolves.toEqual([ + "openai/gpt-4.1", + ]); + }); + + it("preserves concurrent model picker selections for the same scope", async () => { + const env = await createStateEnv(); + const scope = { userId: "concurrent-user" }; + + await Promise.all([ + recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4.1" }), + recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4o" }), + ]); + + const recent = await readDiscordModelPickerRecentModels({ env, scope }); + expect(new Set(recent)).toEqual(new Set(["openai/gpt-4o", "openai/gpt-4.1"])); + }); }); diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index 4d68faf22394..2f1b5c802b34 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -1,55 +1,43 @@ +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id"; -import { withFileLock } from "openclaw/plugin-sdk/file-lock"; -import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; +import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; - -const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { - retries: { - retries: 8, - factor: 2, - minTimeout: 50, - maxTimeout: 5_000, - randomize: true, - }, - stale: 15_000, -} as const; +import { getDiscordRuntime } from "../runtime.js"; const DEFAULT_RECENT_LIMIT = 5; +const PREFERENCE_MAX_ENTRIES = 2_000; +const MAX_PLUGIN_STATE_KEY_BYTES = 512; +const textEncoder = new TextEncoder(); +let lastPreferenceTimestampMs = 0; type ModelPickerPreferencesEntry = { + scopeKey: string; + modelRef: string; + updatedAt: string; +}; + +type LegacyModelPickerPreferencesEntry = { recent: string[]; updatedAt: string; }; -type ModelPickerPreferencesStore = { - version: 1; - entries: Record; +type LegacyModelPickerPreferencesStore = { + version?: unknown; + entries?: unknown; }; -function sanitizePreferenceEntries(entries: unknown): Record { - if (!entries || typeof entries !== "object") { - return {}; - } - const normalizedEntries: Record = {}; - for (const [key, value] of Object.entries(entries)) { - if (!value || typeof value !== "object") { - continue; - } - const typedValue = value as { - recent?: unknown; - updatedAt?: unknown; - }; - const recent = Array.isArray(typedValue.recent) - ? typedValue.recent.filter((item: unknown): item is string => typeof item === "string") - : []; - const updatedAt = typeof typedValue.updatedAt === "string" ? typedValue.updatedAt : ""; - normalizedEntries[key] = { recent, updatedAt }; - } - return normalizedEntries; +const legacyPreferenceImports = new Map>(); + +function openPreferenceStore(env?: NodeJS.ProcessEnv) { + return getDiscordRuntime().state.openKeyedStore({ + namespace: "model-picker-preferences", + maxEntries: PREFERENCE_MAX_ENTRIES, + ...(env ? { env } : {}), + }); } export type DiscordModelPickerPreferenceScope = { @@ -58,11 +46,6 @@ export type DiscordModelPickerPreferenceScope = { userId: string; }; -function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): string { - const stateDir = resolveStateDir(env, os.homedir); - return path.join(stateDir, "discord", "model-picker-preferences.json"); -} - function normalizeId(value?: string): string { return normalizeOptionalString(value) ?? ""; } @@ -116,20 +99,129 @@ function sanitizeRecentModels(models: string[] | undefined, limit: number): stri return deduped; } -async function readPreferencesStore(filePath: string): Promise { - const { value } = await readJsonFileWithFallback(filePath, { - version: 1, - entries: {} as Record, - }); - if (!value || typeof value !== "object" || value.version !== 1) { - return { version: 1, entries: {} }; +function sanitizeLegacyPreferenceEntry( + value: unknown, +): LegacyModelPickerPreferencesEntry | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const typedValue = value as { + recent?: unknown; + updatedAt?: unknown; + }; + const recent = Array.isArray(typedValue.recent) + ? typedValue.recent.filter((item: unknown): item is string => typeof item === "string") + : []; + const updatedAt = typeof typedValue.updatedAt === "string" ? typedValue.updatedAt : ""; + return { recent, updatedAt }; +} + +function sanitizeStoredPreferenceEntry(value: unknown): ModelPickerPreferencesEntry | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const typedValue = value as { + scopeKey?: unknown; + modelRef?: unknown; + updatedAt?: unknown; + }; + if (typeof typedValue.scopeKey !== "string" || typeof typedValue.modelRef !== "string") { + return undefined; + } + const modelRef = normalizeModelRef(typedValue.modelRef); + if (!modelRef) { + return undefined; } return { - version: 1, - entries: sanitizePreferenceEntries(value.entries), + scopeKey: typedValue.scopeKey, + modelRef, + updatedAt: typeof typedValue.updatedAt === "string" ? typedValue.updatedAt : "", }; } +function hashSegment(value: string, length: number): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, length); +} + +function buildPreferenceModelKey(scopeKey: string, modelRef: string): string { + return `v1:${hashSegment(scopeKey, 32)}:${hashSegment(modelRef, 24)}`; +} + +function timestampMs(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function legacyUpdatedAtForIndex(updatedAt: string, index: number, total: number): string { + return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString(); +} + +function nextPreferenceTimestampIso(): string { + lastPreferenceTimestampMs = Math.max(Date.now(), lastPreferenceTimestampMs + 1); + return new Date(lastPreferenceTimestampMs).toISOString(); +} + +function normalizeLegacyPreferenceKey(key: string): string | undefined { + const trimmed = key.trim(); + if (!trimmed || textEncoder.encode(trimmed).length > MAX_PLUGIN_STATE_KEY_BYTES) { + return undefined; + } + return trimmed; +} + +function resolveLegacyPreferencesPath(env?: NodeJS.ProcessEnv): string { + return path.join( + resolveStateDir(env ?? process.env, os.homedir), + "discord", + "model-picker-preferences.json", + ); +} + +async function importLegacyPreferences(env?: NodeJS.ProcessEnv): Promise { + const legacyPath = resolveLegacyPreferencesPath(env); + const stateDir = path.dirname(path.dirname(legacyPath)); + const existingImport = legacyPreferenceImports.get(stateDir); + if (existingImport) { + await existingImport; + return; + } + + const importPromise = (async () => { + const { value, exists } = + await readJsonFileWithFallback(legacyPath, null); + if (!exists || !value || typeof value.entries !== "object" || value.entries == null) { + return; + } + + const store = openPreferenceStore(env); + for (const [rawKey, rawEntry] of Object.entries(value.entries as Record)) { + const key = normalizeLegacyPreferenceKey(rawKey); + if (!key) { + continue; + } + const entry = sanitizeLegacyPreferenceEntry(rawEntry); + if (!entry || (entry.recent.length === 0 && !entry.updatedAt)) { + continue; + } + const recent = sanitizeRecentModels(entry.recent, 10); + for (const [index, modelRef] of recent.entries()) { + await store.registerIfAbsent(buildPreferenceModelKey(key, modelRef), { + scopeKey: key, + modelRef, + updatedAt: legacyUpdatedAtForIndex(entry.updatedAt, index, recent.length), + }); + } + } + })(); + legacyPreferenceImports.set(stateDir, importPromise); + try { + await importPromise; + } catch (error) { + legacyPreferenceImports.delete(stateDir); + throw error; + } +} + export async function readDiscordModelPickerRecentModels(params: { scope: DiscordModelPickerPreferenceScope; limit?: number; @@ -141,14 +233,24 @@ export async function readDiscordModelPickerRecentModels(params: { return []; } const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10)); - const filePath = resolvePreferencesStorePath(params.env); - const store = await readPreferencesStore(filePath); - const entry = store.entries[key]; - const recent = sanitizeRecentModels(entry?.recent, limit); - if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) { - return recent; + try { + await importLegacyPreferences(params.env); + const store = openPreferenceStore(params.env); + const recent = (await store.entries()) + .map((entry) => sanitizeStoredPreferenceEntry(entry.value)) + .filter((entry): entry is ModelPickerPreferencesEntry => entry?.scopeKey === key) + .toSorted((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt)) + .map((entry) => entry.modelRef); + if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) { + return sanitizeRecentModels(recent, limit); + } + return sanitizeRecentModels( + recent.filter((modelRef) => params.allowedModelRefs?.has(modelRef)), + limit, + ); + } catch { + return []; } - return recent.filter((modelRef) => params.allowedModelRefs?.has(modelRef)); } export async function recordDiscordModelPickerRecentModel(params: { @@ -163,22 +265,28 @@ export async function recordDiscordModelPickerRecentModel(params: { return; } - const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10)); - const filePath = resolvePreferencesStorePath(params.env); - - await withFileLock(filePath, MODEL_PICKER_PREFERENCES_LOCK_OPTIONS, async () => { - const store = await readPreferencesStore(filePath); - const existing = sanitizeRecentModels(store.entries[key]?.recent, limit); - const next = [ - normalizedModelRef, - ...existing.filter((entry) => entry !== normalizedModelRef), - ].slice(0, limit); - - store.entries[key] = { - recent: next, - updatedAt: new Date().toISOString(), - }; - - await writeJsonFileAtomically(filePath, store); - }); + try { + await importLegacyPreferences(params.env); + const store = openPreferenceStore(params.env); + await store.register(buildPreferenceModelKey(key, normalizedModelRef), { + scopeKey: key, + modelRef: normalizedModelRef, + updatedAt: nextPreferenceTimestampIso(), + }); + const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10)); + const scopedEntries = (await store.entries()) + .map((entry) => ({ key: entry.key, value: sanitizeStoredPreferenceEntry(entry.value) })) + .filter( + (entry): entry is { key: string; value: ModelPickerPreferencesEntry } => + entry.value?.scopeKey === key, + ) + .toSorted( + (left, right) => + timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) || + left.key.localeCompare(right.key), + ); + await Promise.all(scopedEntries.slice(limit).map((entry) => store.delete(entry.key))); + } catch { + return; + } } diff --git a/extensions/feishu/legacy-state-migrations-api.ts b/extensions/feishu/legacy-state-migrations-api.ts new file mode 100644 index 000000000000..0648be423559 --- /dev/null +++ b/extensions/feishu/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectFeishuLegacyStateMigrations } from "./src/dedup-migrations.js"; diff --git a/extensions/feishu/npm-shrinkwrap.json b/extensions/feishu/npm-shrinkwrap.json index dd558b1c0a4d..d4a8b49fc2bd 100644 --- a/extensions/feishu/npm-shrinkwrap.json +++ b/extensions/feishu/npm-shrinkwrap.json @@ -13,7 +13,7 @@ "zod": "4.4.3" }, "peerDependencies": { - "openclaw": ">=2026.5.28" + "openclaw": ">=2026.5.29" }, "peerDependenciesMeta": { "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 2928e3c874ad..0825fb73ca46 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -17,7 +17,7 @@ "openclaw": "workspace:*" }, "peerDependencies": { - "openclaw": ">=2026.5.28" + "openclaw": ">=2026.5.29" }, "peerDependenciesMeta": { "openclaw": { @@ -29,6 +29,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true + }, "channel": { "id": "feishu", "label": "Feishu", @@ -45,10 +48,10 @@ "install": { "npmSpec": "@openclaw/feishu", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.25" + "minHostVersion": ">=2026.5.29" }, "compat": { - "pluginApi": ">=2026.5.28" + "pluginApi": ">=2026.5.29" }, "build": { "openclawVersion": "2026.5.28" diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index 357953d6022d..1afa04bab800 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -44,7 +44,6 @@ export { resolveSessionStoreEntry, } from "openclaw/plugin-sdk/session-store-runtime"; export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store"; -export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; export { normalizeAgentId } from "openclaw/plugin-sdk/routing"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; export { diff --git a/extensions/feishu/setup-entry.test.ts b/extensions/feishu/setup-entry.test.ts index 14607633e79f..83ed1b193d33 100644 --- a/extensions/feishu/setup-entry.test.ts +++ b/extensions/feishu/setup-entry.test.ts @@ -14,6 +14,8 @@ describe("feishu setup entry", () => { const { default: setupEntry } = await import("./setup-entry.js"); expect(setupEntry.kind).toBe("bundled-channel-setup-entry"); + expect(setupEntry.features).toEqual({ legacyStateMigrations: true }); expect(typeof setupEntry.loadSetupPlugin).toBe("function"); + expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function"); }); }); diff --git a/extensions/feishu/setup-entry.ts b/extensions/feishu/setup-entry.ts index 41216a676d96..bdca1aa6bcab 100644 --- a/extensions/feishu/setup-entry.ts +++ b/extensions/feishu/setup-entry.ts @@ -2,10 +2,17 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, + features: { + legacyStateMigrations: true, + }, plugin: { specifier: "./setup-api.js", exportName: "feishuPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectFeishuLegacyStateMigrations", + }, secrets: { specifier: "./secret-contract-api.js", exportName: "channelSecrets", diff --git a/extensions/feishu/src/dedup-migrations.test.ts b/extensions/feishu/src/dedup-migrations.test.ts new file mode 100644 index 000000000000..5c1e6819bab9 --- /dev/null +++ b/extensions/feishu/src/dedup-migrations.test.ts @@ -0,0 +1,89 @@ +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 { detectFeishuLegacyStateMigrations } from "./dedup-migrations.js"; + +const tempDirs: string[] = []; + +async function makeStateDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-dedup-migration-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + vi.useRealTimers(); + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("Feishu dedupe migration", () => { + it("plans recent legacy dedupe rows with remaining TTL", async () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + const stateDir = await makeStateDir(); + const sourcePath = path.join(stateDir, "feishu", "dedup", "account-a.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + fresh: 1_000, + expired: 2_000 - 24 * 60 * 60 * 1000, + malformed: "nope", + }), + ); + + const plans = await Promise.resolve( + detectFeishuLegacyStateMigrations({ + cfg: {}, + env: {}, + oauthDir: path.join(stateDir, "credentials"), + stateDir, + }), + ); + + if (!plans) { + throw new Error("expected migration plans"); + } + expect(plans).toHaveLength(1); + const plan = plans[0]; + expect(plan?.kind).toBe("plugin-state-import"); + if (plan?.kind !== "plugin-state-import") { + throw new Error("expected plugin-state import plan"); + } + expect(plan.pluginId).toBe("feishu"); + expect(plan.namespace).toBe("dedup.account-a"); + const entries = await plan.readEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.key).toMatch(/^[0-9a-f]{32}$/u); + expect(entries[0]?.value).toEqual({ + namespace: "account-a", + messageId: "fresh", + seenAt: 1_000, + }); + expect(entries[0]?.ttlMs).toBe(24 * 60 * 60 * 1000 - 1_000); + }); + + it("skips expired-only legacy dedupe files", async () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + const stateDir = await makeStateDir(); + const sourcePath = path.join(stateDir, "feishu", "dedup", "account-a.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + expired: 2_000 - 24 * 60 * 60 * 1000, + }), + ); + + expect( + detectFeishuLegacyStateMigrations({ + cfg: {}, + env: {}, + oauthDir: path.join(stateDir, "credentials"), + stateDir, + }), + ).toStrictEqual([]); + }); +}); diff --git a/extensions/feishu/src/dedup-migrations.ts b/extensions/feishu/src/dedup-migrations.ts new file mode 100644 index 000000000000..8acf6e3e6f39 --- /dev/null +++ b/extensions/feishu/src/dedup-migrations.ts @@ -0,0 +1,102 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { BundledChannelLegacyStateMigrationDetector } from "openclaw/plugin-sdk/channel-entry-contract"; + +const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; +const STORE_MAX_ENTRIES = 10_000; + +type LegacyDedupeData = Record; + +function safeNamespaceFromFileName(fileName: string): string | null { + if (!fileName.endsWith(".json")) { + return null; + } + const namespace = fileName.slice(0, -".json".length).trim(); + return namespace ? namespace : null; +} + +function readLegacyDedupeData(filePath: string): LegacyDedupeData { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const out: LegacyDedupeData = {}; + for (const [messageId, seenAt] of Object.entries(parsed as Record)) { + if (typeof seenAt === "number" && Number.isFinite(seenAt) && seenAt > 0) { + out[messageId] = seenAt; + } + } + return out; + } catch { + return {}; + } +} + +function dedupeStoreKey(namespace: string, messageId: string): string { + return createHash("sha256") + .update(`${namespace}\0${messageId}`, "utf8") + .digest("hex") + .slice(0, 32); +} + +function remainingTtlMs(seenAt: number, now: number): number { + return Math.max(1, DEDUP_TTL_MS - (now - seenAt)); +} + +function buildMigrationEntries(namespace: string, sourcePath: string, now: number) { + return Object.entries(readLegacyDedupeData(sourcePath)).flatMap(([messageId, seenAt]) => { + if (now - seenAt >= DEDUP_TTL_MS) { + return []; + } + return [ + { + key: dedupeStoreKey(namespace, messageId), + value: { namespace, messageId, seenAt }, + ttlMs: remainingTtlMs(seenAt, now), + }, + ]; + }); +} + +export const detectFeishuLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({ + stateDir, +}) => { + const dedupDir = path.join(stateDir, "feishu", "dedup"); + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(dedupDir, { withFileTypes: true }); + } catch { + return []; + } + const now = Date.now(); + return entries.flatMap((entry) => { + if (!entry.isFile()) { + return []; + } + const namespace = safeNamespaceFromFileName(entry.name); + if (!namespace) { + return []; + } + const sourcePath = path.join(dedupDir, entry.name); + const migrationEntries = buildMigrationEntries(namespace, sourcePath, now); + if (migrationEntries.length === 0) { + return []; + } + return [ + { + kind: "plugin-state-import" as const, + label: `Feishu ${namespace} dedupe`, + sourcePath, + targetPath: `plugin state:dedup.${namespace}`, + pluginId: "feishu", + namespace: `dedup.${namespace}`, + maxEntries: STORE_MAX_ENTRIES, + scopeKey: "", + cleanupSource: "rename" as const, + readEntries: () => buildMigrationEntries(namespace, sourcePath, now), + }, + ]; + }); +}; diff --git a/extensions/feishu/src/dedup-runtime-api.ts b/extensions/feishu/src/dedup-runtime-api.ts deleted file mode 100644 index e252fbeb4f91..000000000000 --- a/extensions/feishu/src/dedup-runtime-api.ts +++ /dev/null @@ -1 +0,0 @@ -export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; diff --git a/extensions/feishu/src/dedup.test.ts b/extensions/feishu/src/dedup.test.ts new file mode 100644 index 000000000000..1b3f1c749c82 --- /dev/null +++ b/extensions/feishu/src/dedup.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateSyncKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../runtime-api.js"; +import { + hasProcessedFeishuMessage, + testingHooks, + tryRecordMessagePersistent, + warmupDedupFromDisk, +} from "./dedup.js"; +import { setFeishuRuntime } from "./runtime.js"; + +let tempDir: string | undefined; +let previousStateDir: string | undefined; + +beforeEach(async () => { + previousStateDir = process.env.OPENCLAW_STATE_DIR; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-dedup-")); + process.env.OPENCLAW_STATE_DIR = tempDir; + setFeishuRuntime({ + state: { + openSyncKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateSyncKeyedStoreForTests("feishu", options), + }, + } as unknown as PluginRuntime); + testingHooks.resetFeishuDedupForTests(); +}); + +afterEach(async () => { + vi.useRealTimers(); + testingHooks.resetFeishuDedupForTests(); + resetPluginStateStoreForTests(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + tempDir = undefined; +}); + +describe("Feishu persistent dedupe", () => { + it("records message ids in plugin state", async () => { + await expect(tryRecordMessagePersistent("msg-1", "account-a")).resolves.toBe(true); + await expect(tryRecordMessagePersistent("msg-1", "account-a")).resolves.toBe(false); + await expect(hasProcessedFeishuMessage("msg-1", "account-a")).resolves.toBe(true); + await expect(hasProcessedFeishuMessage("msg-1", "account-b")).resolves.toBe(false); + }); + + it("warms memory from persisted plugin state", async () => { + await expect(tryRecordMessagePersistent("msg-2", "account-a")).resolves.toBe(true); + testingHooks.resetFeishuDedupMemoryForTests(); + + await expect(warmupDedupFromDisk("account-a")).resolves.toBe(1); + await expect(tryRecordMessagePersistent("msg-2", "account-a")).resolves.toBe(false); + }); + + it("ignores expired persisted entries", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + await expect(tryRecordMessagePersistent("msg-3", "account-a")).resolves.toBe(true); + testingHooks.resetFeishuDedupMemoryForTests(); + + vi.setSystemTime(1_000 + 24 * 60 * 60 * 1000 + 1); + await expect(hasProcessedFeishuMessage("msg-3", "account-a")).resolves.toBe(false); + }); + + it("imports legacy JSON dedupe entries before checking plugin state", async () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + const legacyPath = path.join(tempDir as string, "feishu", "dedup", "account-a.json"); + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile( + legacyPath, + JSON.stringify({ + "msg-legacy": 1_000, + "msg-expired": 2_000 - 24 * 60 * 60 * 1000 - 1, + }), + "utf8", + ); + + await expect(hasProcessedFeishuMessage("msg-legacy", "account-a")).resolves.toBe(true); + await expect(tryRecordMessagePersistent("msg-legacy", "account-a")).resolves.toBe(false); + await expect(hasProcessedFeishuMessage("msg-expired", "account-a")).resolves.toBe(false); + }); +}); diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts index f73c0ee75220..3e0c606b1f1b 100644 --- a/extensions/feishu/src/dedup.ts +++ b/extensions/feishu/src/dedup.ts @@ -1,17 +1,40 @@ +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; -import { createPersistentDedupe } from "./dedup-runtime-api.js"; +import { loadJsonFile } from "openclaw/plugin-sdk/json-store"; +import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { releaseFeishuMessageProcessing, tryBeginFeishuMessageProcessing, } from "./processing-claims.js"; +import { getFeishuRuntime } from "./runtime.js"; // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects. const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; const MEMORY_MAX_SIZE = 1_000; -const FILE_MAX_ENTRIES = 10_000; +const STORE_MAX_ENTRIES = 10_000; +type FeishuDedupStoreEntry = { + namespace: string; + messageId: string; + seenAt: number; +}; -function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { +const memory = new Map(); +const importedLegacyNamespaces = new Set(); +const cachedDedupStores = new Map>(); + +type LegacyDedupeData = Record; + +function normalizeMessageId(messageId: string | undefined | null): string | null { + const trimmed = messageId?.trim(); + return trimmed ? trimmed : null; +} + +function normalizeNamespace(namespace?: string): string { + return namespace?.trim() || "global"; +} + +function resolveLegacyStateDir(env: NodeJS.ProcessEnv = process.env): string { const stateOverride = env.OPENCLAW_STATE_DIR?.trim(); if (stateOverride) { return stateOverride; @@ -22,21 +45,121 @@ function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { return path.join(os.homedir(), ".openclaw"); } -function resolveNamespaceFilePath(namespace: string): string { +function resolveLegacyNamespaceFilePath(namespace: string): string { const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_"); - return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`); + return path.join(resolveLegacyStateDir(), "feishu", "dedup", `${safe}.json`); } -const persistentDedupe = createPersistentDedupe({ - ttlMs: DEDUP_TTL_MS, - memoryMaxSize: MEMORY_MAX_SIZE, - fileMaxEntries: FILE_MAX_ENTRIES, - resolveFilePath: resolveNamespaceFilePath, -}); +function pluginStateNamespace(namespace: string): string { + return `dedup.${namespace.replace(/[^a-zA-Z0-9_-]/g, "_")}`; +} -function normalizeMessageId(messageId: string | undefined | null): string | null { - const trimmed = messageId?.trim(); - return trimmed ? trimmed : null; +function openDedupStore(namespace: string): PluginStateSyncKeyedStore { + const stateNamespace = pluginStateNamespace(namespace); + const cached = cachedDedupStores.get(stateNamespace); + if (cached) { + return cached; + } + const store = getFeishuRuntime().state.openSyncKeyedStore({ + namespace: stateNamespace, + maxEntries: STORE_MAX_ENTRIES, + defaultTtlMs: DEDUP_TTL_MS, + }); + cachedDedupStores.set(stateNamespace, store); + return store; +} + +function dedupeStoreKey(namespace: string, messageId: string): string { + return createHash("sha256") + .update(`${namespace}\0${messageId}`, "utf8") + .digest("hex") + .slice(0, 32); +} + +function memoryKey(namespace: string, messageId: string): string { + return `${namespace}\0${messageId}`; +} + +function isRecent(seenAt: number | undefined, now = Date.now()): boolean { + return typeof seenAt === "number" && Number.isFinite(seenAt) && now - seenAt < DEDUP_TTL_MS; +} + +function pruneMemory(now = Date.now()): void { + for (const [key, seenAt] of memory) { + if (!isRecent(seenAt, now)) { + memory.delete(key); + } + } + if (memory.size <= MEMORY_MAX_SIZE) { + return; + } + const toRemove = Array.from(memory.entries()) + .toSorted(([, left], [, right]) => left - right) + .slice(0, memory.size - MEMORY_MAX_SIZE); + for (const [key] of toRemove) { + memory.delete(key); + } +} + +function remember(namespace: string, messageId: string, seenAt = Date.now()): void { + memory.set(memoryKey(namespace, messageId), seenAt); + pruneMemory(seenAt); +} + +function hasMemory(namespace: string, messageId: string, now = Date.now()): boolean { + const key = memoryKey(namespace, messageId); + const seenAt = memory.get(key); + if (isRecent(seenAt, now)) { + return true; + } + memory.delete(key); + return false; +} + +function sanitizeLegacyDedupeData(value: unknown): LegacyDedupeData { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const out: LegacyDedupeData = {}; + for (const [key, seenAt] of Object.entries(value as Record)) { + if (typeof seenAt === "number" && Number.isFinite(seenAt) && seenAt > 0) { + out[key] = seenAt; + } + } + return out; +} + +function importLegacyDedupNamespace( + namespace: string, + now = Date.now(), + log?: (...args: unknown[]) => void, +): void { + if (importedLegacyNamespaces.has(namespace)) { + return; + } + + try { + const data = sanitizeLegacyDedupeData(loadJsonFile(resolveLegacyNamespaceFilePath(namespace))); + const store = openDedupStore(namespace); + for (const [messageId, seenAt] of Object.entries(data)) { + if (!isRecent(seenAt, now)) { + continue; + } + const key = dedupeStoreKey(namespace, messageId); + if (store.lookup(key) != null) { + continue; + } + store.register( + key, + { namespace, messageId, seenAt }, + { ttlMs: Math.max(1, DEDUP_TTL_MS - (now - seenAt)) }, + ); + } + importedLegacyNamespaces.add(namespace); + } catch (error) { + importedLegacyNamespaces.delete(namespace); + log?.(`feishu-dedup: legacy state import failed: ${String(error)}`); + } } export { releaseFeishuMessageProcessing, tryBeginFeishuMessageProcessing }; @@ -110,12 +233,58 @@ export async function tryRecordMessagePersistent( namespace = "global", log?: (...args: unknown[]) => void, ): Promise { - return persistentDedupe.checkAndRecord(messageId, { - namespace, - onDiskError: (error) => { - log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`); - }, - }); + const normalizedNamespace = normalizeNamespace(namespace); + const normalizedMessageId = normalizeMessageId(messageId); + if (!normalizedMessageId) { + return true; + } + const now = Date.now(); + importLegacyDedupNamespace(normalizedNamespace, now, log); + if (hasMemory(normalizedNamespace, normalizedMessageId, now)) { + return false; + } + const key = dedupeStoreKey(normalizedNamespace, normalizedMessageId); + try { + const store = openDedupStore(normalizedNamespace); + const existing = store.lookup(key); + const existingSeenAt = existing?.seenAt; + if (isRecent(existingSeenAt, now)) { + remember(normalizedNamespace, normalizedMessageId, existingSeenAt); + return false; + } + const recorded = store.registerIfAbsent( + key, + { + namespace: normalizedNamespace, + messageId: normalizedMessageId, + seenAt: now, + }, + { ttlMs: DEDUP_TTL_MS }, + ); + if (!recorded) { + const current = store.lookup(key); + const currentSeenAt = current?.seenAt; + if (isRecent(currentSeenAt, now)) { + remember(normalizedNamespace, normalizedMessageId, currentSeenAt); + return false; + } + store.register( + key, + { + namespace: normalizedNamespace, + messageId: normalizedMessageId, + seenAt: now, + }, + { ttlMs: DEDUP_TTL_MS }, + ); + } + remember(normalizedNamespace, normalizedMessageId, now); + return true; + } catch (error) { + log?.(`feishu-dedup: persistent state error, falling back to memory: ${String(error)}`); + remember(normalizedNamespace, normalizedMessageId, now); + return true; + } } async function hasRecordedMessagePersistent( @@ -123,19 +292,64 @@ async function hasRecordedMessagePersistent( namespace = "global", log?: (...args: unknown[]) => void, ): Promise { - return persistentDedupe.hasRecent(messageId, { - namespace, - onDiskError: (error) => { - log?.(`feishu-dedup: persistent peek failed: ${String(error)}`); - }, - }); + const normalizedNamespace = normalizeNamespace(namespace); + const normalizedMessageId = normalizeMessageId(messageId); + if (!normalizedMessageId) { + return false; + } + const now = Date.now(); + importLegacyDedupNamespace(normalizedNamespace, now, log); + if (hasMemory(normalizedNamespace, normalizedMessageId, now)) { + return true; + } + try { + const store = openDedupStore(normalizedNamespace); + const existing = store.lookup(dedupeStoreKey(normalizedNamespace, normalizedMessageId)); + const existingSeenAt = existing?.seenAt; + if (!isRecent(existingSeenAt, now)) { + return false; + } + remember(normalizedNamespace, normalizedMessageId, existingSeenAt); + return true; + } catch (error) { + log?.(`feishu-dedup: persistent peek failed: ${String(error)}`); + return hasMemory(normalizedNamespace, normalizedMessageId, now); + } } export async function warmupDedupFromDisk( namespace: string, log?: (...args: unknown[]) => void, ): Promise { - return persistentDedupe.warmup(namespace, (error) => { - log?.(`feishu-dedup: warmup disk error: ${String(error)}`); - }); + const normalizedNamespace = normalizeNamespace(namespace); + try { + let loaded = 0; + const now = Date.now(); + importLegacyDedupNamespace(normalizedNamespace, now, log); + for (const entry of openDedupStore(normalizedNamespace).entries()) { + if (entry.value.namespace !== normalizedNamespace || !isRecent(entry.value.seenAt, now)) { + continue; + } + remember(normalizedNamespace, entry.value.messageId, entry.value.seenAt); + loaded++; + } + return loaded; + } catch (error) { + log?.(`feishu-dedup: warmup persistent state error: ${String(error)}`); + return 0; + } } + +export const testingHooks = { + resetFeishuDedupForTests() { + memory.clear(); + importedLegacyNamespaces.clear(); + for (const store of cachedDedupStores.values()) { + store.clear(); + } + cachedDedupStores.clear(); + }, + resetFeishuDedupMemoryForTests() { + memory.clear(); + }, +}; diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index a4e0d1d25a66..ea41a0d8112e 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -29,6 +29,9 @@ "openclaw/plugin-sdk/plugin-test-contracts": [ "../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-contracts.d.ts" ], + "openclaw/plugin-sdk/plugin-state-test-runtime": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/plugin-state-test-runtime.d.ts" + ], "openclaw/plugin-sdk/plugin-test-runtime": [ "../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-runtime.d.ts" ], diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 5185d603865e..b4a92551965b 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -30,6 +30,9 @@ "openclaw/plugin-sdk/plugin-test-contracts": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-contracts.d.ts" ], + "openclaw/plugin-sdk/plugin-state-test-runtime": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-state-test-runtime.d.ts" + ], "openclaw/plugin-sdk/plugin-test-runtime": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/plugin-test-runtime.d.ts" ], diff --git a/package.json b/package.json index 3df42afa8145..3520bc1a1df8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "!dist/plugin-sdk/plugin-test-api.d.ts", "!dist/plugin-sdk/plugin-test-contracts.js", "!dist/plugin-sdk/plugin-test-contracts.d.ts", + "!dist/plugin-sdk/plugin-state-test-runtime.js", + "!dist/plugin-sdk/plugin-state-test-runtime.d.ts", "!dist/plugin-sdk/plugin-test-runtime.js", "!dist/plugin-sdk/plugin-test-runtime.d.ts", "!dist/plugin-sdk/provider-http-test-mocks.js", @@ -637,6 +639,10 @@ "types": "./dist/plugin-sdk/migration-runtime.d.ts", "default": "./dist/plugin-sdk/migration-runtime.js" }, + "./plugin-sdk/plugin-state-runtime": { + "types": "./dist/plugin-sdk/plugin-state-runtime.d.ts", + "default": "./dist/plugin-sdk/plugin-state-runtime.js" + }, "./plugin-sdk/markdown-table-runtime": { "types": "./dist/plugin-sdk/markdown-table-runtime.d.ts", "default": "./dist/plugin-sdk/markdown-table-runtime.js" diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index ce64a33ba4a0..f66ee85860d6 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -167,7 +167,7 @@ "install": { "npmSpec": "@openclaw/feishu", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.25" + "minHostVersion": ">=2026.5.29" } } }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 05782291993e..ec9e9b67ee66 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -137,6 +137,8 @@ "logging-core", "migration", "migration-runtime", + "plugin-state-runtime", + "plugin-state-test-runtime", "markdown-table-runtime", "account-helpers", "account-core", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 82f65e719aa5..0c3f60f5f3d4 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -6,6 +6,7 @@ "codex-native-task-runtime", "plugin-test-api", "plugin-test-contracts", + "plugin-state-test-runtime", "plugin-test-runtime", "provider-http-test-mocks", "provider-test-contracts", diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 9b6e4799c12f..21a3f1fca0fd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -174,8 +174,8 @@ export type ChannelLegacyStateMigrationPlan = cleanupSource?: "rename"; preview?: string; readEntries: () => - | Array<{ key: string; value: unknown }> - | Promise>; + | Array<{ key: string; value: unknown; ttlMs?: number }> + | Promise>; }; /** User-facing metadata used in docs, pickers, and setup surfaces. */ diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index fa586a5d3c2a..094902d18693 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { + MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, createPluginStateKeyedStore, resetPluginStateStoreForTests, } from "../plugin-state/plugin-state-store.js"; @@ -650,7 +651,7 @@ describe("doctor legacy state migrations", () => { maxEntries: 4, scopeKey: "", cleanupSource: "rename", - readEntries: () => [{ key: "default", value: { body: "global" } }], + readEntries: () => [{ key: "default", value: { body: "global" }, ttlMs: 60_000 }], }, ]; @@ -709,6 +710,8 @@ describe("doctor legacy state migrations", () => { expect(Object.fromEntries(globalValuesByKey)).toEqual({ default: "global", }); + const globalEntries = await globalStore.entries(); + expect(globalEntries[0]?.expiresAt).toBeGreaterThan(Date.now()); }); }); @@ -724,7 +727,7 @@ describe("doctor legacy state migrations", () => { targetPath: "plugin state:test.capped-cache", pluginId: "telegram", namespace: "test.capped-cache", - maxEntries: 6_000, + maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, scopeKey: "scope", cleanupSource: "rename", readEntries: () => [ @@ -736,7 +739,7 @@ describe("doctor legacy state migrations", () => { await withStateDir(root, async () => { seedPluginStateEntriesForTests( - Array.from({ length: 5_999 }, (_, index) => ({ + Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN - 1 }, (_, index) => ({ pluginId: "telegram", namespace: "test.sibling-cache", key: `sibling-${index}`, @@ -765,7 +768,7 @@ describe("doctor legacy state migrations", () => { await withStateDir(root, async () => { const store = createPluginStateKeyedStore<{ body: string }>("telegram", { namespace: "test.capped-cache", - maxEntries: 6_000, + maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, }); const valuesByKey = new Map( (await store.entries()).map(({ key, value }) => [key, value.body]), diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 7e66c9043e8f..30c9b9726425 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -210,7 +210,11 @@ async function runLegacyMigrationPlans( break; } try { - await store.register(targetKey, entry.value); + await store.register( + targetKey, + entry.value, + entry.ttlMs != null ? { ttlMs: entry.ttlMs } : undefined, + ); const nextExpectedKeys = new Set(expectedKeys); nextExpectedKeys.add(targetKey); const liveKeys = new Set((await store.entries()).map(({ key }) => key)); diff --git a/src/plugin-sdk/plugin-state-runtime.ts b/src/plugin-sdk/plugin-state-runtime.ts new file mode 100644 index 000000000000..70178b8d3b73 --- /dev/null +++ b/src/plugin-sdk/plugin-state-runtime.ts @@ -0,0 +1,6 @@ +export type { + OpenKeyedStoreOptions, + PluginStateEntry, + PluginStateKeyedStore, + PluginStateSyncKeyedStore, +} from "../plugin-state/plugin-state-store.js"; diff --git a/src/plugin-sdk/plugin-state-test-runtime.ts b/src/plugin-sdk/plugin-state-test-runtime.ts new file mode 100644 index 000000000000..7750b4f3d335 --- /dev/null +++ b/src/plugin-sdk/plugin-state-test-runtime.ts @@ -0,0 +1,5 @@ +export { + createPluginStateKeyedStore as createPluginStateKeyedStoreForTests, + createPluginStateSyncKeyedStore as createPluginStateSyncKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "../plugin-state/plugin-state-store.js"; diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index 80dcc8614fc3..5b622ce8f88e 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -764,6 +764,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial = openKeyedStore: vi.fn(() => { throw new Error("openKeyedStore mock is not configured"); }) as unknown as PluginRuntime["state"]["openKeyedStore"], + openSyncKeyedStore: vi.fn(() => { + throw new Error("openSyncKeyedStore mock is not configured"); + }) as unknown as PluginRuntime["state"]["openSyncKeyedStore"], }, tasks: { runs: { diff --git a/src/plugin-state/plugin-state-store.runtime.test.ts b/src/plugin-state/plugin-state-store.runtime.test.ts index 758c13a658ab..79960743cfbe 100644 --- a/src/plugin-state/plugin-state-store.runtime.test.ts +++ b/src/plugin-state/plugin-state-store.runtime.test.ts @@ -57,6 +57,9 @@ function createTestPluginRegistry() { openKeyedStore: () => { throw new Error("registry plugin runtime proxy should bind openKeyedStore"); }, + openSyncKeyedStore: () => { + throw new Error("registry plugin runtime proxy should bind openSyncKeyedStore"); + }, }, } as unknown as PluginRuntime, }); @@ -91,6 +94,13 @@ describe("plugin runtime state proxy", () => { }); await expect(telegramStore.lookup("k")).resolves.toBeUndefined(); await expect(store.lookup("k")).resolves.toEqual({ plugin: "discord" }); + + const syncStore = api.runtime.state.openSyncKeyedStore<{ plugin: string }>({ + namespace: "sync-runtime", + maxEntries: 10, + }); + expect(syncStore.registerIfAbsent("k", { plugin: "discord" })).toBe(true); + expect(syncStore.lookup("k")).toEqual({ plugin: "discord" }); }); }); @@ -119,6 +129,9 @@ describe("plugin runtime state proxy", () => { expect(() => api.runtime.state.openKeyedStore({ namespace: "runtime", maxEntries: 10 }), ).toThrow("openKeyedStore is only available for trusted plugins"); + expect(() => + api.runtime.state.openSyncKeyedStore({ namespace: "runtime", maxEntries: 10 }), + ).toThrow("openKeyedStore is only available for trusted plugins"); }); it("rejects untrusted global plugins", () => { diff --git a/src/plugin-state/plugin-state-store.sqlite.ts b/src/plugin-state/plugin-state-store.sqlite.ts index bba375535a84..cdd7cc492e2e 100644 --- a/src/plugin-state/plugin-state-store.sqlite.ts +++ b/src/plugin-state/plugin-state-store.sqlite.ts @@ -17,7 +17,7 @@ const PLUGIN_STATE_DIR_MODE = 0o700; const PLUGIN_STATE_FILE_MODE = 0o600; const PLUGIN_STATE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const; // Plugin-wide fuse only; namespace maxEntries still owns normal cache eviction. -const MAX_ENTRIES_PER_PLUGIN = 6_000; +const MAX_ENTRIES_PER_PLUGIN = 50_000; export const MAX_PLUGIN_STATE_VALUE_BYTES = 65_536; export const MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN = MAX_ENTRIES_PER_PLUGIN; @@ -98,7 +98,7 @@ function wrapPluginStateError( operation: PluginStateStoreOperation, fallbackCode: PluginStateStoreErrorCode, message: string, - pathname = resolvePluginStateSqlitePath(process.env), + pathname: string = resolvePluginStateSqlitePath(process.env), ): PluginStateStoreError { if (error instanceof PluginStateStoreError) { return error; @@ -282,8 +282,8 @@ function createStatements(db: DatabaseSync): PluginStateStatements { }; } -function ensurePluginStatePermissions(pathname: string) { - const dir = resolvePluginStateDir(process.env); +function ensurePluginStatePermissions(pathname: string, env: NodeJS.ProcessEnv = process.env) { + const dir = resolvePluginStateDir(env); mkdirSync(dir, { recursive: true, mode: PLUGIN_STATE_DIR_MODE }); chmodSync(dir, PLUGIN_STATE_DIR_MODE); for (const suffix of PLUGIN_STATE_SIDECAR_SUFFIXES) { @@ -294,9 +294,12 @@ function ensurePluginStatePermissions(pathname: string) { } } -function ensurePluginStatePermissionsBestEffort(pathname: string): void { +function ensurePluginStatePermissionsBestEffort( + pathname: string, + env: NodeJS.ProcessEnv = process.env, +): void { try { - ensurePluginStatePermissions(pathname); + ensurePluginStatePermissions(pathname, env); } catch { // The write already committed. Permission hardening is best-effort from here. } @@ -304,8 +307,9 @@ function ensurePluginStatePermissionsBestEffort(pathname: string): void { function openPluginStateDatabase( operation: PluginStateStoreOperation = "open", + env: NodeJS.ProcessEnv = process.env, ): PluginStateDatabase { - const pathname = resolvePluginStateSqlitePath(process.env); + const pathname = resolvePluginStateSqlitePath(env); if (cachedDatabase && cachedDatabase.path === pathname) { return cachedDatabase; } @@ -316,7 +320,7 @@ function openPluginStateDatabase( } try { - ensurePluginStatePermissions(pathname); + ensurePluginStatePermissions(pathname, env); } catch (error) { throw createPluginStateError({ code: "PLUGIN_STATE_OPEN_FAILED", @@ -346,7 +350,7 @@ function openPluginStateDatabase( db.exec("PRAGMA synchronous = NORMAL;"); db.exec("PRAGMA busy_timeout = 5000;"); ensureSchema(db, pathname); - ensurePluginStatePermissions(pathname); + ensurePluginStatePermissions(pathname, env); cachedDatabase = { db, path: pathname, @@ -373,14 +377,15 @@ function countRow(row: CountRow | undefined): number { function runWriteTransaction( operation: PluginStateStoreOperation, write: (store: PluginStateDatabase) => T, + env: NodeJS.ProcessEnv = process.env, ): T { - const store = openPluginStateDatabase(operation); - ensurePluginStatePermissions(store.path); + const store = openPluginStateDatabase(operation, env); + ensurePluginStatePermissions(store.path, env); store.db.exec("BEGIN IMMEDIATE"); try { const result = write(store); store.db.exec("COMMIT"); - ensurePluginStatePermissionsBestEffort(store.path); + ensurePluginStatePermissionsBestEffort(store.path, env); return result; } catch (error) { try { @@ -456,35 +461,41 @@ export function pluginStateRegister(params: { valueJson: string; maxEntries: number; ttlMs?: number; + env?: NodeJS.ProcessEnv; }): void { try { - runWriteTransaction("register", (store) => { - const now = Date.now(); - const expiresAt = params.ttlMs == null ? null : now + params.ttlMs; - store.statements.pruneExpiredNamespace.run(params.pluginId, params.namespace, now); - store.statements.upsertEntry.run({ - plugin_id: params.pluginId, - namespace: params.namespace, - entry_key: params.key, - value_json: params.valueJson, - created_at: now, - expires_at: expiresAt, - }); - enforcePostRegisterLimits({ - store, - pluginId: params.pluginId, - namespace: params.namespace, - maxEntries: params.maxEntries, - now, - protectedKey: params.key, - }); - }); + runWriteTransaction( + "register", + (store) => { + const now = Date.now(); + const expiresAt = params.ttlMs == null ? null : now + params.ttlMs; + store.statements.pruneExpiredNamespace.run(params.pluginId, params.namespace, now); + store.statements.upsertEntry.run({ + plugin_id: params.pluginId, + namespace: params.namespace, + entry_key: params.key, + value_json: params.valueJson, + created_at: now, + expires_at: expiresAt, + }); + enforcePostRegisterLimits({ + store, + pluginId: params.pluginId, + namespace: params.namespace, + maxEntries: params.maxEntries, + now, + protectedKey: params.key, + }); + }, + params.env, + ); } catch (error) { throw wrapPluginStateError( error, "register", "PLUGIN_STATE_WRITE_FAILED", "Failed to register plugin state entry.", + resolvePluginStateSqlitePath(params.env), ); } } @@ -496,39 +507,45 @@ export function pluginStateRegisterIfAbsent(params: { valueJson: string; maxEntries: number; ttlMs?: number; + env?: NodeJS.ProcessEnv; }): boolean { try { - return runWriteTransaction("register", (store) => { - const now = Date.now(); - const expiresAt = params.ttlMs == null ? null : now + params.ttlMs; - store.statements.pruneExpiredNamespace.run(params.pluginId, params.namespace, now); - const result = store.statements.insertEntryIfAbsent.run({ - plugin_id: params.pluginId, - namespace: params.namespace, - entry_key: params.key, - value_json: params.valueJson, - created_at: now, - expires_at: expiresAt, - }); - if (result.changes === 0) { - return false; - } - enforcePostRegisterLimits({ - store, - pluginId: params.pluginId, - namespace: params.namespace, - maxEntries: params.maxEntries, - now, - protectedKey: params.key, - }); - return true; - }); + return runWriteTransaction( + "register", + (store) => { + const now = Date.now(); + const expiresAt = params.ttlMs == null ? null : now + params.ttlMs; + store.statements.pruneExpiredNamespace.run(params.pluginId, params.namespace, now); + const result = store.statements.insertEntryIfAbsent.run({ + plugin_id: params.pluginId, + namespace: params.namespace, + entry_key: params.key, + value_json: params.valueJson, + created_at: now, + expires_at: expiresAt, + }); + if (result.changes === 0) { + return false; + } + enforcePostRegisterLimits({ + store, + pluginId: params.pluginId, + namespace: params.namespace, + maxEntries: params.maxEntries, + now, + protectedKey: params.key, + }); + return true; + }, + params.env, + ); } catch (error) { throw wrapPluginStateError( error, "register", "PLUGIN_STATE_WRITE_FAILED", "Failed to register plugin state entry.", + resolvePluginStateSqlitePath(params.env), ); } } @@ -537,9 +554,10 @@ export function pluginStateLookup(params: { pluginId: string; namespace: string; key: string; + env?: NodeJS.ProcessEnv; }): unknown { try { - const { statements } = openPluginStateDatabase("lookup"); + const { statements } = openPluginStateDatabase("lookup", params.env); const row = statements.selectEntry.get( params.pluginId, params.namespace, @@ -553,6 +571,7 @@ export function pluginStateLookup(params: { "lookup", "PLUGIN_STATE_READ_FAILED", "Failed to read plugin state entry.", + resolvePluginStateSqlitePath(params.env), ); } } @@ -561,27 +580,33 @@ export function pluginStateConsume(params: { pluginId: string; namespace: string; key: string; + env?: NodeJS.ProcessEnv; }): unknown { try { - return runWriteTransaction("consume", (store) => { - const row = store.statements.selectEntry.get( - params.pluginId, - params.namespace, - params.key, - Date.now(), - ) as PluginStateRow | undefined; - if (!row) { - return undefined; - } - store.statements.deleteEntry.run(params.pluginId, params.namespace, params.key); - return parseStoredJson(row.value_json, "consume"); - }); + return runWriteTransaction( + "consume", + (store) => { + const row = store.statements.selectEntry.get( + params.pluginId, + params.namespace, + params.key, + Date.now(), + ) as PluginStateRow | undefined; + if (!row) { + return undefined; + } + store.statements.deleteEntry.run(params.pluginId, params.namespace, params.key); + return parseStoredJson(row.value_json, "consume"); + }, + params.env, + ); } catch (error) { throw wrapPluginStateError( error, "consume", "PLUGIN_STATE_READ_FAILED", "Failed to consume plugin state entry.", + resolvePluginStateSqlitePath(params.env), ); } } @@ -590,9 +615,10 @@ export function pluginStateDelete(params: { pluginId: string; namespace: string; key: string; + env?: NodeJS.ProcessEnv; }): boolean { try { - const { statements } = openPluginStateDatabase("delete"); + const { statements } = openPluginStateDatabase("delete", params.env); const result = statements.deleteEntry.run(params.pluginId, params.namespace, params.key); return result.changes > 0; } catch (error) { @@ -601,6 +627,7 @@ export function pluginStateDelete(params: { "delete", "PLUGIN_STATE_WRITE_FAILED", "Failed to delete plugin state entry.", + resolvePluginStateSqlitePath(params.env), ); } } @@ -608,9 +635,10 @@ export function pluginStateDelete(params: { export function pluginStateEntries(params: { pluginId: string; namespace: string; + env?: NodeJS.ProcessEnv; }): PluginStateEntry[] { try { - const { statements } = openPluginStateDatabase("entries"); + const { statements } = openPluginStateDatabase("entries", params.env); const rows = statements.selectEntries.all( params.pluginId, params.namespace, @@ -623,6 +651,7 @@ export function pluginStateEntries(params: { "entries", "PLUGIN_STATE_READ_FAILED", "Failed to list plugin state entries.", + resolvePluginStateSqlitePath(params.env), ); } } @@ -641,9 +670,13 @@ export function countPluginStateLiveEntries(pluginId: string): number { } } -export function pluginStateClear(params: { pluginId: string; namespace: string }): void { +export function pluginStateClear(params: { + pluginId: string; + namespace: string; + env?: NodeJS.ProcessEnv; +}): void { try { - const { statements } = openPluginStateDatabase("clear"); + const { statements } = openPluginStateDatabase("clear", params.env); statements.clearNamespace.run(params.pluginId, params.namespace); } catch (error) { throw wrapPluginStateError( @@ -651,6 +684,7 @@ export function pluginStateClear(params: { pluginId: string; namespace: string } "clear", "PLUGIN_STATE_WRITE_FAILED", "Failed to clear plugin state namespace.", + resolvePluginStateSqlitePath(params.env), ); } } diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index d2c9d2f365aa..ffa1a43e403b 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -6,10 +6,12 @@ import { type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; import { + MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, clearPluginStateStoreForTests, closePluginStateSqliteStore, createCorePluginStateKeyedStore, createPluginStateKeyedStore, + createPluginStateSyncKeyedStore, PluginStateStoreError, probePluginStateStore, resetPluginStateStoreForTests, @@ -78,6 +80,22 @@ describe("plugin state keyed store", () => { }); }); + it("supports synchronous keyed store callers", async () => { + await withPluginStateTestState(async () => { + const store = createPluginStateSyncKeyedStore<{ count: number }>("discord", { + namespace: "sync-components", + maxEntries: 10, + }); + + expect(store.registerIfAbsent("interaction:1", { count: 1 })).toBe(true); + expect(store.registerIfAbsent("interaction:1", { count: 2 })).toBe(false); + expect(store.lookup("interaction:1")).toEqual({ count: 1 }); + expect(store.entries()).toMatchObject([{ key: "interaction:1", value: { count: 1 } }]); + expect(store.consume("interaction:1")).toEqual({ count: 1 }); + expect(store.lookup("interaction:1")).toBeUndefined(); + }); + }); + it("upserts values and refreshes deterministic entry ordering", async () => { await withPluginStateTestState(async () => { vi.useFakeTimers(); @@ -210,7 +228,7 @@ describe("plugin state keyed store", () => { expect((await evicting.entries()).map((entry) => entry.key)).toEqual(["b", "c"]); seedPluginStateEntriesForTests([ - ...Array.from({ length: 5_999 }, (_, entryIndex) => ({ + ...Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN - 1 }, (_, entryIndex) => ({ pluginId: "limited-plugin", namespace: "limit", key: `k-${entryIndex}`, @@ -225,7 +243,7 @@ describe("plugin state keyed store", () => { ]); const limited = createPluginStateKeyedStore("limited-plugin", { namespace: "limit", - maxEntries: 6_001, + maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN + 1, }); const sibling = createPluginStateKeyedStore("limited-plugin", { namespace: "sibling", @@ -342,7 +360,7 @@ describe("plugin state keyed store", () => { it("evicts current namespace rows when sibling namespaces consume plugin row budget", async () => { await withPluginStateTestState(async () => { seedPluginStateEntriesForTests([ - ...Array.from({ length: 5_989 }, (_, entryIndex) => ({ + ...Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN - 11 }, (_, entryIndex) => ({ pluginId: "telegram", namespace: "telegram.message-cache", key: `k-${entryIndex}`, @@ -358,7 +376,7 @@ describe("plugin state keyed store", () => { const messageStore = createPluginStateKeyedStore("telegram", { namespace: "telegram.message-cache", - maxEntries: 6_000, + maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, }); const topicStore = createPluginStateKeyedStore("telegram", { namespace: "telegram.topic-name-cache", @@ -378,7 +396,9 @@ describe("plugin state keyed store", () => { kind: "topic", entryIndex: 0, }); - await expect(messageStore.entries()).resolves.toHaveLength(5_989); + await expect(messageStore.entries()).resolves.toHaveLength( + MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN - 11, + ); await expect(topicStore.entries()).resolves.toHaveLength(11); }); }); @@ -436,7 +456,7 @@ describe("plugin state keyed store", () => { it("rejects plugin overflow when the current namespace cannot shed old rows", async () => { await withPluginStateTestState(async () => { seedPluginStateEntriesForTests( - Array.from({ length: 6_000 }, (_, entryIndex) => ({ + Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN }, (_, entryIndex) => ({ pluginId: "telegram", namespace: "telegram.topic-name-cache", key: `topic-${entryIndex}`, @@ -446,11 +466,11 @@ describe("plugin state keyed store", () => { const messageStore = createPluginStateKeyedStore("telegram", { namespace: "telegram.message-cache", - maxEntries: 6_000, + maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, }); const topicStore = createPluginStateKeyedStore("telegram", { namespace: "telegram.topic-name-cache", - maxEntries: 6_000, + maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN, }); await expectPluginStateStoreError(messageStore.register("new-message", { fresh: true }), { diff --git a/src/plugin-state/plugin-state-store.ts b/src/plugin-state/plugin-state-store.ts index 3b227432be1d..8685c3d4502b 100644 --- a/src/plugin-state/plugin-state-store.ts +++ b/src/plugin-state/plugin-state-store.ts @@ -14,6 +14,7 @@ import type { OpenKeyedStoreOptions, PluginStateEntry, PluginStateKeyedStore, + PluginStateSyncKeyedStore, PluginStateStoreOperation, } from "./plugin-state-store.types.js"; import { PluginStateStoreError } from "./plugin-state-store.types.js"; @@ -22,6 +23,7 @@ export type { OpenKeyedStoreOptions, PluginStateEntry, PluginStateKeyedStore, + PluginStateSyncKeyedStore, PluginStateStoreErrorCode, PluginStateStoreOperation, PluginStateStoreProbeResult, @@ -47,6 +49,12 @@ type StoreOptionSignature = { defaultTtlMs?: number; }; +type PreparedRegisterParams = { + key: string; + valueJson: string; + ttlMs?: number; +}; + const namespaceOptionSignatures = new Map(); const textEncoder = new TextEncoder(); @@ -190,6 +198,27 @@ function assertValueSize(json: string): void { } } +function prepareRegisterParams( + key: string, + value: unknown, + defaultTtlMs?: number, + opts?: { ttlMs?: number }, +): PreparedRegisterParams { + const normalizedKey = validateKey(key, "register"); + assertJsonSerializable(value); + const json = JSON.stringify(value); + if (json === undefined) { + throw invalidInput("plugin state value must be JSON-serializable", "register"); + } + assertValueSize(json); + const ttlMs = validateOptionalTtlMs(opts?.ttlMs, "register") ?? defaultTtlMs; + return { + key: normalizedKey, + valueJson: json, + ...(ttlMs != null ? { ttlMs } : {}), + }; +} + function assertConsistentOptions( pluginId: string, namespace: string, @@ -219,65 +248,145 @@ function createKeyedStoreForPluginId( const namespace = validateNamespace(options.namespace); const maxEntries = validateMaxEntries(options.maxEntries); const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs); + const env = options.env; assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs }); - const prepareRegisterParams = ( - key: string, - value: T, - opts?: { ttlMs?: number }, - ): { key: string; valueJson: string; ttlMs?: number } => { - const normalizedKey = validateKey(key, "register"); - assertJsonSerializable(value); - const json = JSON.stringify(value); - assertValueSize(json); - const ttlMs = validateOptionalTtlMs(opts?.ttlMs, "register") ?? defaultTtlMs; - return { - key: normalizedKey, - valueJson: json, - ...(ttlMs != null ? { ttlMs } : {}), - }; - }; - return { async register(key, value, opts) { - const params = prepareRegisterParams(key, value, opts); + const params = prepareRegisterParams(key, value, defaultTtlMs, opts); pluginStateRegister({ pluginId, namespace, key: params.key, valueJson: params.valueJson, maxEntries, + ...(env ? { env } : {}), ...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}), }); }, async registerIfAbsent(key, value, opts) { - const params = prepareRegisterParams(key, value, opts); + const params = prepareRegisterParams(key, value, defaultTtlMs, opts); return pluginStateRegisterIfAbsent({ pluginId, namespace, key: params.key, valueJson: params.valueJson, maxEntries, + ...(env ? { env } : {}), ...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}), }); }, async lookup(key) { const normalizedKey = validateKey(key, "lookup"); - return pluginStateLookup({ pluginId, namespace, key: normalizedKey }) as T | undefined; + return pluginStateLookup({ + pluginId, + namespace, + key: normalizedKey, + ...(env ? { env } : {}), + }) as T | undefined; }, async consume(key) { const normalizedKey = validateKey(key, "consume"); - return pluginStateConsume({ pluginId, namespace, key: normalizedKey }) as T | undefined; + return pluginStateConsume({ + pluginId, + namespace, + key: normalizedKey, + ...(env ? { env } : {}), + }) as T | undefined; }, async delete(key) { const normalizedKey = validateKey(key, "delete"); - return pluginStateDelete({ pluginId, namespace, key: normalizedKey }); + return pluginStateDelete({ + pluginId, + namespace, + key: normalizedKey, + ...(env ? { env } : {}), + }); }, async entries() { - return pluginStateEntries({ pluginId, namespace }) as PluginStateEntry[]; + return pluginStateEntries({ + pluginId, + namespace, + ...(env ? { env } : {}), + }) as PluginStateEntry[]; }, async clear() { - pluginStateClear({ pluginId, namespace }); + pluginStateClear({ pluginId, namespace, ...(env ? { env } : {}) }); + }, + }; +} + +function createSyncKeyedStoreForPluginId( + pluginId: string, + options: OpenKeyedStoreOptions, +): PluginStateSyncKeyedStore { + const namespace = validateNamespace(options.namespace); + const maxEntries = validateMaxEntries(options.maxEntries); + const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs); + const env = options.env; + assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs }); + + return { + register(key, value, opts) { + const params = prepareRegisterParams(key, value, defaultTtlMs, opts); + pluginStateRegister({ + pluginId, + namespace, + key: params.key, + valueJson: params.valueJson, + maxEntries, + ...(env ? { env } : {}), + ...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}), + }); + }, + registerIfAbsent(key, value, opts) { + const params = prepareRegisterParams(key, value, defaultTtlMs, opts); + return pluginStateRegisterIfAbsent({ + pluginId, + namespace, + key: params.key, + valueJson: params.valueJson, + maxEntries, + ...(env ? { env } : {}), + ...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}), + }); + }, + lookup(key) { + const normalizedKey = validateKey(key, "lookup"); + return pluginStateLookup({ + pluginId, + namespace, + key: normalizedKey, + ...(env ? { env } : {}), + }) as T | undefined; + }, + consume(key) { + const normalizedKey = validateKey(key, "consume"); + return pluginStateConsume({ + pluginId, + namespace, + key: normalizedKey, + ...(env ? { env } : {}), + }) as T | undefined; + }, + delete(key) { + const normalizedKey = validateKey(key, "delete"); + return pluginStateDelete({ + pluginId, + namespace, + key: normalizedKey, + ...(env ? { env } : {}), + }); + }, + entries() { + return pluginStateEntries({ + pluginId, + namespace, + ...(env ? { env } : {}), + }) as PluginStateEntry[]; + }, + clear() { + pluginStateClear({ pluginId, namespace, ...(env ? { env } : {}) }); }, }; } @@ -292,12 +401,28 @@ export function createPluginStateKeyedStore( return createKeyedStoreForPluginId(pluginId, options); } +export function createPluginStateSyncKeyedStore( + pluginId: string, + options: OpenKeyedStoreOptions, +): PluginStateSyncKeyedStore { + if (pluginId.startsWith("core:")) { + throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open"); + } + return createSyncKeyedStoreForPluginId(pluginId, options); +} + export function createCorePluginStateKeyedStore( options: OpenKeyedStoreOptions & { ownerId: `core:${string}` }, ): PluginStateKeyedStore { return createKeyedStoreForPluginId(options.ownerId, options); } +export function createCorePluginStateSyncKeyedStore( + options: OpenKeyedStoreOptions & { ownerId: `core:${string}` }, +): PluginStateSyncKeyedStore { + return createSyncKeyedStoreForPluginId(options.ownerId, options); +} + export function clearPluginStateStoreForTests(): void { clearPluginStateSqliteStoreForTests(); namespaceOptionSignatures.clear(); diff --git a/src/plugin-state/plugin-state-store.types.ts b/src/plugin-state/plugin-state-store.types.ts index 9c07e95a7b36..79613aa21486 100644 --- a/src/plugin-state/plugin-state-store.types.ts +++ b/src/plugin-state/plugin-state-store.types.ts @@ -15,10 +15,21 @@ export type PluginStateKeyedStore = { clear(): Promise; }; +export type PluginStateSyncKeyedStore = { + register(key: string, value: T, opts?: { ttlMs?: number }): void; + registerIfAbsent(key: string, value: T, opts?: { ttlMs?: number }): boolean; + lookup(key: string): T | undefined; + consume(key: string): T | undefined; + delete(key: string): boolean; + entries(): PluginStateEntry[]; + clear(): void; +}; + export type OpenKeyedStoreOptions = { namespace: string; maxEntries: number; defaultTtlMs?: number; + env?: NodeJS.ProcessEnv; }; export type PluginStateStoreErrorCode = diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 563ffb99d4c0..0d75775afbb0 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -28,8 +28,10 @@ import { } from "../infra/node-commands.js"; import { createPluginStateKeyedStore, + createPluginStateSyncKeyedStore, type OpenKeyedStoreOptions, type PluginStateKeyedStore, + type PluginStateSyncKeyedStore, } from "../plugin-state/plugin-state-store.js"; import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; @@ -2573,19 +2575,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }; if (prop === "state") { const baseState = getRuntimeProperty(); + const assertPluginStateAllowed = () => { + const record = + pluginRuntimeRecordById.get(pluginId) ?? + registry.plugins.find((entry) => entry.id === pluginId); + if (record?.origin !== "bundled" && record?.trustedOfficialInstall !== true) { + throw new Error( + "openKeyedStore is only available for trusted plugins in this release.", + ); + } + }; return { ...baseState, openKeyedStore: (options: OpenKeyedStoreOptions): PluginStateKeyedStore => { - const record = - pluginRuntimeRecordById.get(pluginId) ?? - registry.plugins.find((entry) => entry.id === pluginId); - if (record?.origin !== "bundled" && record?.trustedOfficialInstall !== true) { - throw new Error( - "openKeyedStore is only available for trusted plugins in this release.", - ); - } + assertPluginStateAllowed(); return createPluginStateKeyedStore(pluginId, options); }, + openSyncKeyedStore: ( + options: OpenKeyedStoreOptions, + ): PluginStateSyncKeyedStore => { + assertPluginStateAllowed(); + return createPluginStateSyncKeyedStore(pluginId, options); + }, } satisfies PluginRuntime["state"]; } if (prop === "config") { diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index f704f410e6c5..ea9632e66c03 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -253,6 +253,9 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): openKeyedStore: () => { throw new Error("openKeyedStore is only available through the plugin runtime proxy."); }, + openSyncKeyedStore: () => { + throw new Error("openSyncKeyedStore is only available through the plugin runtime proxy."); + }, }, tasks, taskFlow, diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 0277d6942f62..dc9fb755c673 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -311,6 +311,9 @@ export type PluginRuntimeCore = { openKeyedStore: ( options: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions, ) => import("../../plugin-state/plugin-state-store.types.js").PluginStateKeyedStore; + openSyncKeyedStore: ( + options: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions, + ) => import("../../plugin-state/plugin-state-store.types.js").PluginStateSyncKeyedStore; }; tasks: { runs: PluginRuntimeTaskRuns;