mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 01:43:29 +08:00
Compare commits
1 Commits
v2026.6.8
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c283167ac0 |
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
|
||||
- Plugins/SDK: allow plugins to use `api.runtime.state.openKeyedStore` with the store owner bound internally to the runtime plugin id and no plugin-controlled namespace.
|
||||
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
|
||||
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
|
||||
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f829dd720df7c9c8eb9d59eda3b3f879bff278f74b4c00d8d788c1483865b649 plugin-sdk-api-baseline.json
|
||||
1b3504c8f9ddd00801f095f94f417d469b47370064478eae389d33f4b8e10c76 plugin-sdk-api-baseline.jsonl
|
||||
5f8788711b083737a113a099d56fc2d69322640b86cfa55359c377f0f8800071 plugin-sdk-api-baseline.json
|
||||
dbb89462d8553b5c1071f46a9e34e04fa3f42a0cfee2f306d498e8587feedf19 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -411,7 +411,6 @@ Provider and channel execution paths must use the active runtime config snapshot
|
||||
```typescript
|
||||
const stateDir = api.runtime.state.resolveStateDir(process.env);
|
||||
const store = api.runtime.state.openKeyedStore<MyRecord>({
|
||||
namespace: "my-feature",
|
||||
maxEntries: 200,
|
||||
defaultTtlMs: 15 * 60_000,
|
||||
});
|
||||
@@ -422,11 +421,9 @@ Provider and channel execution paths must use the active runtime config snapshot
|
||||
await store.clear();
|
||||
```
|
||||
|
||||
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Limits: `maxEntries` per namespace, 1,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry.
|
||||
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. The SDK does not accept an owner id or namespace, so one plugin cannot read, write, list, consume, or clear another plugin's state. Limits: default 100 entries, optional `maxEntries` up to 1,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry.
|
||||
|
||||
<Warning>
|
||||
Bundled plugins only in this release.
|
||||
</Warning>
|
||||
Omit `maxEntries` to use the default row budget.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.tools">
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
|
||||
import { getOptionalDiscordRuntime } from "./runtime.js";
|
||||
|
||||
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
|
||||
const PERSISTENT_COMPONENT_NAMESPACE = "discord.components";
|
||||
const PERSISTENT_MODAL_NAMESPACE = "discord.modals";
|
||||
const PERSISTENT_COMPONENT_MAX_ENTRIES = 500;
|
||||
const PERSISTENT_MODAL_MAX_ENTRIES = 500;
|
||||
const PERSISTENT_COMPONENT_KEY_PREFIX = "component";
|
||||
const PERSISTENT_MODAL_KEY_PREFIX = "modal";
|
||||
const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries");
|
||||
const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries");
|
||||
|
||||
@@ -76,7 +76,6 @@ function getPersistentComponentStore(): DiscordRegistryStore<DiscordComponentEnt
|
||||
persistentComponentStore = runtime.state.openKeyedStore<
|
||||
PersistedDiscordRegistryEntry<DiscordComponentEntry>
|
||||
>({
|
||||
namespace: PERSISTENT_COMPONENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_COMPONENT_MAX_ENTRIES,
|
||||
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
|
||||
});
|
||||
@@ -102,7 +101,6 @@ function getPersistentModalStore(): DiscordRegistryStore<DiscordModalEntry> | un
|
||||
persistentModalStore = runtime.state.openKeyedStore<
|
||||
PersistedDiscordRegistryEntry<DiscordModalEntry>
|
||||
>({
|
||||
namespace: PERSISTENT_MODAL_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MODAL_MAX_ENTRIES,
|
||||
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
|
||||
});
|
||||
@@ -178,6 +176,7 @@ function readPersistedRegistryEntry<T extends { id: string }>(
|
||||
function registerPersistentRegistryEntries<T extends { id: string }>(params: {
|
||||
entries: T[];
|
||||
ttlMs: number;
|
||||
keyPrefix: string;
|
||||
openStore: () => DiscordRegistryStore<T> | undefined;
|
||||
}): void {
|
||||
if (params.entries.length === 0) {
|
||||
@@ -189,7 +188,7 @@ function registerPersistentRegistryEntries<T extends { id: string }>(params: {
|
||||
}
|
||||
for (const entry of params.entries) {
|
||||
void store
|
||||
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
|
||||
.register(`${params.keyPrefix}:${entry.id}`, { version: 1, entry }, { ttlMs: params.ttlMs })
|
||||
.catch(disablePersistentComponentRegistry);
|
||||
}
|
||||
}
|
||||
@@ -202,24 +201,27 @@ function registerPersistentEntries(params: {
|
||||
registerPersistentRegistryEntries({
|
||||
entries: params.entries,
|
||||
ttlMs: params.ttlMs,
|
||||
keyPrefix: PERSISTENT_COMPONENT_KEY_PREFIX,
|
||||
openStore: getPersistentComponentStore,
|
||||
});
|
||||
registerPersistentRegistryEntries({
|
||||
entries: params.modals,
|
||||
ttlMs: params.ttlMs,
|
||||
keyPrefix: PERSISTENT_MODAL_KEY_PREFIX,
|
||||
openStore: getPersistentModalStore,
|
||||
});
|
||||
}
|
||||
|
||||
function deletePersistentEntry<T extends { id: string }>(params: {
|
||||
id: string;
|
||||
keyPrefix: string;
|
||||
openStore: () => DiscordRegistryStore<T> | undefined;
|
||||
}): void {
|
||||
const store = params.openStore();
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
void store.delete(params.id).catch(disablePersistentComponentRegistry);
|
||||
void store.delete(`${params.keyPrefix}:${params.id}`).catch(disablePersistentComponentRegistry);
|
||||
}
|
||||
|
||||
function resolveComponentConsumptionIds(entry: DiscordComponentEntry): string[] {
|
||||
@@ -243,13 +245,16 @@ function deletePersistentComponentConsumptionGroup(entry: DiscordComponentEntry)
|
||||
return;
|
||||
}
|
||||
for (const id of resolveComponentConsumptionIds(entry)) {
|
||||
void store.delete(id).catch(disablePersistentComponentRegistry);
|
||||
void store
|
||||
.delete(`${PERSISTENT_COMPONENT_KEY_PREFIX}:${id}`)
|
||||
.catch(disablePersistentComponentRegistry);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePersistentRegistryEntry<T extends { id: string }>(params: {
|
||||
id: string;
|
||||
consume?: boolean;
|
||||
keyPrefix: string;
|
||||
openStore: () => DiscordRegistryStore<T> | undefined;
|
||||
}): Promise<T | null> {
|
||||
const store = params.openStore();
|
||||
@@ -257,8 +262,8 @@ async function resolvePersistentRegistryEntry<T extends { id: string }>(params:
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value =
|
||||
params.consume === false ? await store.lookup(params.id) : await store.consume(params.id);
|
||||
const key = `${params.keyPrefix}:${params.id}`;
|
||||
const value = params.consume === false ? await store.lookup(key) : await store.consume(key);
|
||||
return readPersistedRegistryEntry(value);
|
||||
} catch (error) {
|
||||
disablePersistentComponentRegistry(error);
|
||||
@@ -315,6 +320,7 @@ export async function resolveDiscordComponentEntryWithPersistence(params: {
|
||||
}
|
||||
const persisted = await resolvePersistentRegistryEntry({
|
||||
...params,
|
||||
keyPrefix: PERSISTENT_COMPONENT_KEY_PREFIX,
|
||||
openStore: getPersistentComponentStore,
|
||||
});
|
||||
if (persisted && params.consume !== false) {
|
||||
@@ -337,12 +343,17 @@ export async function resolveDiscordModalEntryWithPersistence(params: {
|
||||
const inMemory = resolveDiscordModalEntry(params);
|
||||
if (inMemory) {
|
||||
if (params.consume !== false) {
|
||||
deletePersistentEntry({ ...params, openStore: getPersistentModalStore });
|
||||
deletePersistentEntry({
|
||||
...params,
|
||||
keyPrefix: PERSISTENT_MODAL_KEY_PREFIX,
|
||||
openStore: getPersistentModalStore,
|
||||
});
|
||||
}
|
||||
return inMemory;
|
||||
}
|
||||
return await resolvePersistentRegistryEntry({
|
||||
...params,
|
||||
keyPrefix: PERSISTENT_MODAL_KEY_PREFIX,
|
||||
openStore: getPersistentModalStore,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,35 +178,31 @@ describe("discord component registry", () => {
|
||||
});
|
||||
|
||||
it("persists component and modal entries when runtime state is available", async () => {
|
||||
const componentRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const modalRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const componentLookup = vi.fn().mockResolvedValue({
|
||||
version: 1,
|
||||
entry: { id: "btn_persisted", kind: "button", label: "Persisted" },
|
||||
const register = vi.fn().mockResolvedValue(undefined);
|
||||
const lookup = vi.fn(async (key: string) => {
|
||||
if (key === "component:btn_persisted") {
|
||||
return {
|
||||
version: 1,
|
||||
entry: { id: "btn_persisted", kind: "button", label: "Persisted" },
|
||||
};
|
||||
}
|
||||
if (key === "modal:mdl_persisted") {
|
||||
return {
|
||||
version: 1,
|
||||
entry: { id: "mdl_persisted", title: "Persisted", fields: [] },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const modalLookup = vi.fn().mockResolvedValue({
|
||||
version: 1,
|
||||
entry: { id: "mdl_persisted", title: "Persisted", fields: [] },
|
||||
});
|
||||
const componentStore = {
|
||||
register: componentRegister,
|
||||
lookup: componentLookup,
|
||||
const store = {
|
||||
register,
|
||||
lookup,
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const modalStore = {
|
||||
register: modalRegister,
|
||||
lookup: modalLookup,
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
|
||||
opts.namespace === "discord.components" ? componentStore : modalStore,
|
||||
);
|
||||
const openKeyedStore = vi.fn(() => store);
|
||||
const { setDiscordRuntime } = await import("./runtime.js");
|
||||
setDiscordRuntime({
|
||||
state: { openKeyedStore },
|
||||
@@ -219,14 +215,14 @@ describe("discord component registry", () => {
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
|
||||
expect(componentRegister).toHaveBeenCalledWith(
|
||||
"btn_1",
|
||||
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(2));
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
"component:btn_1",
|
||||
{ version: 1, entry: expect.objectContaining({ id: "btn_1" }) },
|
||||
{ ttlMs: 1000 },
|
||||
);
|
||||
expect(modalRegister).toHaveBeenCalledWith(
|
||||
"mdl_1",
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
"modal:mdl_1",
|
||||
{ version: 1, entry: expect.objectContaining({ id: "mdl_1" }) },
|
||||
{ ttlMs: 1000 },
|
||||
);
|
||||
@@ -238,14 +234,14 @@ describe("discord component registry", () => {
|
||||
await expect(
|
||||
resolveDiscordModalEntryWithPersistence({ id: "mdl_persisted", consume: false }),
|
||||
).resolves.toMatchObject({ id: "mdl_persisted" });
|
||||
expect(componentLookup).toHaveBeenCalledWith("btn_persisted");
|
||||
expect(modalLookup).toHaveBeenCalledWith("mdl_persisted");
|
||||
expect(lookup).toHaveBeenCalledWith("component:btn_persisted");
|
||||
expect(lookup).toHaveBeenCalledWith("modal:mdl_persisted");
|
||||
expect(openKeyedStore).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("deletes sibling persistent component entries when a group entry is consumed", async () => {
|
||||
const componentDelete = vi.fn().mockResolvedValue(true);
|
||||
const componentStore = {
|
||||
const deleteEntry = vi.fn().mockResolvedValue(true);
|
||||
const store = {
|
||||
register: vi.fn(),
|
||||
lookup: vi.fn(),
|
||||
consume: vi.fn().mockResolvedValue({
|
||||
@@ -258,17 +254,9 @@ describe("discord component registry", () => {
|
||||
consumptionGroupEntryIds: ["btn_confirm", "btn_cancel"],
|
||||
},
|
||||
}),
|
||||
delete: componentDelete,
|
||||
delete: deleteEntry,
|
||||
};
|
||||
const modalStore = {
|
||||
register: vi.fn(),
|
||||
lookup: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
|
||||
opts.namespace === "discord.components" ? componentStore : modalStore,
|
||||
);
|
||||
const openKeyedStore = vi.fn(() => store);
|
||||
const { setDiscordRuntime } = await import("./runtime.js");
|
||||
setDiscordRuntime({
|
||||
state: { openKeyedStore },
|
||||
@@ -282,8 +270,8 @@ describe("discord component registry", () => {
|
||||
id: "btn_confirm",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(componentDelete).toHaveBeenCalledWith("btn_cancel"));
|
||||
expect(componentDelete).toHaveBeenCalledWith("btn_confirm");
|
||||
await vi.waitFor(() => expect(deleteEntry).toHaveBeenCalledWith("component:btn_cancel"));
|
||||
expect(deleteEntry).toHaveBeenCalledWith("component:btn_confirm");
|
||||
});
|
||||
|
||||
it("falls back to the in-memory registry when persistent state cannot open", async () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ const MATRIX_APPROVAL_REACTION_ORDER = [
|
||||
"deny",
|
||||
] as const satisfies readonly ExecApprovalReplyDecision[];
|
||||
|
||||
const PERSISTENT_NAMESPACE = "matrix.approval-reactions";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -99,7 +98,6 @@ function getPersistentApprovalReactionStore(): MatrixApprovalReactionStore | und
|
||||
}
|
||||
try {
|
||||
persistentStore = runtime.state.openKeyedStore<PersistedMatrixApprovalReactionTarget>({
|
||||
namespace: PERSISTENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { getOptionalMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const PERSISTENT_NAMESPACE = "msteams.sent-messages";
|
||||
const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("openclaw.msteamsSentMessages");
|
||||
|
||||
type MSTeamsSentMessageRecord = {
|
||||
@@ -62,7 +61,6 @@ function getPersistentSentMessageStore(): MSTeamsSentMessageStore | undefined {
|
||||
}
|
||||
try {
|
||||
persistentStore = runtime.state.openKeyedStore<MSTeamsSentMessageRecord>({
|
||||
namespace: PERSISTENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
defaultTtlMs: TTL_MS,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getOptionalSlackRuntime } from "./runtime.js";
|
||||
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const MAX_ENTRIES = 5000;
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const PERSISTENT_NAMESPACE = "slack.thread-participation";
|
||||
|
||||
type SlackThreadParticipationRecord = {
|
||||
agentId?: string;
|
||||
@@ -72,7 +71,6 @@ function getPersistentThreadParticipationStore(): SlackThreadParticipationStore
|
||||
}
|
||||
try {
|
||||
persistentStore = runtime.state.openKeyedStore<SlackThreadParticipationRecord>({
|
||||
namespace: PERSISTENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
defaultTtlMs: TTL_MS,
|
||||
});
|
||||
|
||||
@@ -10,3 +10,8 @@ export * from "../plugins/lazy-service-module.js";
|
||||
export * from "../plugins/types.js";
|
||||
export { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
|
||||
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
|
||||
export type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateEntry,
|
||||
PluginStateKeyedStore,
|
||||
} from "../plugin-state/plugin-state-store.types.js";
|
||||
|
||||
@@ -26,7 +26,6 @@ describe("runtime smoke", () => {
|
||||
it("creates and exercises a keyed store directly", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-smoke-load" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ ready: boolean }>("fixture-plugin", {
|
||||
namespace: "boot",
|
||||
maxEntries: 10,
|
||||
});
|
||||
expect(store).toBeDefined();
|
||||
@@ -39,7 +38,6 @@ describe("runtime smoke", () => {
|
||||
it("writes and reads a value", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-smoke-rw" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ msg: string }>("fixture-plugin", {
|
||||
namespace: "data",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("greeting", { msg: "hello" });
|
||||
@@ -50,7 +48,6 @@ describe("runtime smoke", () => {
|
||||
it("consumes a value exactly once", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-smoke-consume" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ token: string }>("fixture-plugin", {
|
||||
namespace: "tokens",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("one-shot", { token: "abc123" });
|
||||
@@ -73,7 +70,6 @@ describe("persistence", () => {
|
||||
it("survives close and reopen of the store", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-persist" }, async () => {
|
||||
const storeA = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", {
|
||||
namespace: "durable",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await storeA.register("key1", { persisted: true });
|
||||
@@ -84,7 +80,6 @@ describe("persistence", () => {
|
||||
resetPluginStateStoreForTests();
|
||||
|
||||
const storeB = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", {
|
||||
namespace: "durable",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await expect(storeB.lookup("key1")).resolves.toEqual({ persisted: true });
|
||||
@@ -103,7 +98,6 @@ describe("TTL", () => {
|
||||
vi.setSystemTime(10_000);
|
||||
|
||||
const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", {
|
||||
namespace: "ttl-test",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("short", { v: 1 }, { ttlMs: 500 });
|
||||
@@ -136,14 +130,12 @@ describe("TTL", () => {
|
||||
// Isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isolation", () => {
|
||||
it("segregates plugins sharing namespace and key", async () => {
|
||||
it("segregates plugins sharing a key", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-isolation" }, async () => {
|
||||
const pluginA = createPluginStateKeyedStore<{ owner: string }>("plugin-a", {
|
||||
namespace: "x",
|
||||
maxEntries: 10,
|
||||
});
|
||||
const pluginB = createPluginStateKeyedStore<{ owner: string }>("plugin-b", {
|
||||
namespace: "x",
|
||||
maxEntries: 10,
|
||||
});
|
||||
|
||||
@@ -153,7 +145,7 @@ describe("isolation", () => {
|
||||
await expect(pluginA.lookup("same")).resolves.toEqual({ owner: "a" });
|
||||
await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" });
|
||||
|
||||
// Clearing one plugin's namespace does not affect the other.
|
||||
// Clearing one plugin's store does not affect the other.
|
||||
await pluginA.clear();
|
||||
await expect(pluginA.lookup("same")).resolves.toBeUndefined();
|
||||
await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" });
|
||||
@@ -168,7 +160,6 @@ describe("limits", () => {
|
||||
it("accepts a value at the 64 KB boundary", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-limit-accept" }, async () => {
|
||||
const store = createPluginStateKeyedStore<string>("fixture-plugin", {
|
||||
namespace: "size",
|
||||
maxEntries: 10,
|
||||
});
|
||||
// JSON.stringify wraps a string in quotes (+2 bytes).
|
||||
@@ -182,7 +173,6 @@ describe("limits", () => {
|
||||
it("rejects a value one byte over 64 KB", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-limit-reject" }, async () => {
|
||||
const store = createPluginStateKeyedStore<string>("fixture-plugin", {
|
||||
namespace: "size",
|
||||
maxEntries: 10,
|
||||
});
|
||||
// 65 535 chars → 65 537 bytes of JSON → over limit.
|
||||
@@ -193,41 +183,31 @@ describe("limits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the per-plugin live-row cap", async () => {
|
||||
it("evicts oldest entries at the plugin-wide live-row cap", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-limit-plugin" }, async () => {
|
||||
// Spread MAX_ENTRIES_PER_PLUGIN rows across several namespaces so
|
||||
// namespace eviction never fires (each namespace has generous room).
|
||||
const nsCount = 10;
|
||||
const perNs = MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN / nsCount; // 100
|
||||
seedPluginStateEntriesForTests(
|
||||
Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN }, (_, index) => {
|
||||
const ns = Math.floor(index / perNs);
|
||||
const k = index % perNs;
|
||||
return {
|
||||
pluginId: "fixture-plugin",
|
||||
namespace: `ns-${ns}`,
|
||||
key: `k-${k}`,
|
||||
value: { ns, k },
|
||||
key: `k-${index}`,
|
||||
value: { index },
|
||||
};
|
||||
}),
|
||||
);
|
||||
const store = createPluginStateKeyedStore("fixture-plugin", {
|
||||
namespace: "ns-0",
|
||||
maxEntries: perNs + 1,
|
||||
maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
|
||||
});
|
||||
|
||||
// One more row tips over the plugin-wide limit.
|
||||
await expect(store.register("overflow", { boom: true })).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
|
||||
});
|
||||
await expect(store.register("overflow", { boom: true })).resolves.toBeUndefined();
|
||||
await expect(store.lookup("k-0")).resolves.toBeUndefined();
|
||||
await expect(store.lookup("overflow")).resolves.toEqual({ boom: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts oldest entries when namespace maxEntries is exceeded", async () => {
|
||||
it("evicts oldest entries when maxEntries is exceeded", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-limit-eviction" }, async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createPluginStateKeyedStore<number>("fixture-plugin", {
|
||||
namespace: "capped",
|
||||
maxEntries: 3,
|
||||
});
|
||||
|
||||
@@ -262,7 +242,6 @@ describe("failure safety", () => {
|
||||
db.close();
|
||||
|
||||
const store = createPluginStateKeyedStore("fixture-plugin", {
|
||||
namespace: "schema",
|
||||
maxEntries: 10,
|
||||
});
|
||||
const error = await store.register("k", { ok: true }).catch((e: unknown) => e);
|
||||
@@ -288,7 +267,6 @@ describe("failure safety", () => {
|
||||
it("close and reopen cycle is clean", async () => {
|
||||
await withOpenClawTestState({ label: "e2e-fail-reopen" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", {
|
||||
namespace: "reopen",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("k", { v: 1 });
|
||||
|
||||
@@ -38,7 +38,6 @@ describe("plugin state permission hardening", () => {
|
||||
try {
|
||||
await withOpenClawTestState({ label: "plugin-state-post-commit-chmod" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ value: number }>("fixture-plugin", {
|
||||
namespace: "post-commit",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("first", { value: 1 });
|
||||
|
||||
@@ -62,7 +62,6 @@ describe("plugin runtime state proxy", () => {
|
||||
|
||||
expect(api.runtime.state.resolveStateDir()).toBe(state.stateDir);
|
||||
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
|
||||
namespace: "runtime",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("k", { plugin: "discord" });
|
||||
@@ -71,7 +70,6 @@ describe("plugin runtime state proxy", () => {
|
||||
registry.registry.plugins.push(telegram);
|
||||
const telegramApi = registry.createApi(telegram, { config: {} });
|
||||
const telegramStore = telegramApi.runtime.state.openKeyedStore<{ plugin: string }>({
|
||||
namespace: "runtime",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await expect(telegramStore.lookup("k")).resolves.toBeUndefined();
|
||||
@@ -79,14 +77,40 @@ describe("plugin runtime state proxy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects external plugins in this release", () => {
|
||||
const registry = createTestPluginRegistry();
|
||||
const record = createPluginRecord("external-plugin", "workspace");
|
||||
registry.registry.plugins.push(record);
|
||||
const api = registry.createApi(record, { config: {} });
|
||||
it("allows workspace plugins and keeps owner isolation", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-runtime-workspace" }, async () => {
|
||||
const registry = createTestPluginRegistry();
|
||||
const external = createPluginRecord("external-state-plugin", "workspace");
|
||||
registry.registry.plugins.push(external);
|
||||
const api = registry.createApi(external, { config: {} });
|
||||
|
||||
expect(() =>
|
||||
api.runtime.state.openKeyedStore({ namespace: "runtime", maxEntries: 10 }),
|
||||
).toThrow("openKeyedStore is only available for bundled plugins");
|
||||
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("k", { plugin: "external-state-plugin" });
|
||||
|
||||
const sibling = createPluginRecord("sibling-state-plugin", "workspace");
|
||||
registry.registry.plugins.push(sibling);
|
||||
const siblingApi = registry.createApi(sibling, { config: {} });
|
||||
const siblingStore = siblingApi.runtime.state.openKeyedStore<{ plugin: string }>({
|
||||
maxEntries: 10,
|
||||
});
|
||||
|
||||
await expect(siblingStore.lookup("k")).resolves.toBeUndefined();
|
||||
await expect(store.lookup("k")).resolves.toEqual({ plugin: "external-state-plugin" });
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a default row budget when options are omitted", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-runtime-defaults" }, async () => {
|
||||
const registry = createTestPluginRegistry();
|
||||
const record = createPluginRecord("external-plugin", "workspace");
|
||||
registry.registry.plugins.push(record);
|
||||
const api = registry.createApi(record, { config: {} });
|
||||
|
||||
const store = api.runtime.state.openKeyedStore<{ ok: boolean }>({});
|
||||
await store.register("k", { ok: true });
|
||||
await expect(store.lookup("k")).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { closePluginStateSqliteStore, probePluginStateStore } from "./plugin-sta
|
||||
|
||||
export type PluginStateSeedEntry = {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
namespace?: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
createdAt?: number;
|
||||
@@ -50,7 +50,7 @@ export function seedPluginStateEntriesForTests(entries: PluginStateSeedEntry[]):
|
||||
}
|
||||
insertEntry.run({
|
||||
plugin_id: entry.pluginId,
|
||||
namespace: entry.namespace,
|
||||
namespace: entry.namespace ?? "default",
|
||||
entry_key: entry.key,
|
||||
value_json: valueJson,
|
||||
created_at: entry.createdAt ?? now + index,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
sweepExpiredPluginStateEntries,
|
||||
} from "./plugin-state-store.js";
|
||||
import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
|
||||
import { seedPluginStateEntriesForTests } from "./plugin-state-store.test-helpers.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -23,13 +22,11 @@ describe("plugin state keyed store", () => {
|
||||
it("registers and looks up values across store instances", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-roundtrip" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ count: number }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("interaction:1", { count: 1 });
|
||||
|
||||
const reopened = createPluginStateKeyedStore<{ count: number }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await expect(reopened.lookup("interaction:1")).resolves.toEqual({ count: 1 });
|
||||
@@ -40,7 +37,6 @@ describe("plugin state keyed store", () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-upsert" }, async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createPluginStateKeyedStore<{ version: number }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
vi.setSystemTime(1000);
|
||||
@@ -61,7 +57,6 @@ describe("plugin state keyed store", () => {
|
||||
it("returns undefined for missing lookups and consumes by deleting atomically", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-consume" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ ok: boolean }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
|
||||
@@ -73,20 +68,20 @@ describe("plugin state keyed store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes and clears only the targeted namespace", async () => {
|
||||
it("deletes and clears only the owning plugin store", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-clear" }, async () => {
|
||||
const first = createPluginStateKeyedStore("discord", { namespace: "a", maxEntries: 10 });
|
||||
const second = createPluginStateKeyedStore("discord", { namespace: "b", maxEntries: 10 });
|
||||
await first.register("k1", { value: 1 });
|
||||
await second.register("k2", { value: 2 });
|
||||
const discord = createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
const telegram = createPluginStateKeyedStore("telegram", { maxEntries: 10 });
|
||||
await discord.register("k1", { value: 1 });
|
||||
await telegram.register("k2", { value: 2 });
|
||||
|
||||
await expect(first.delete("k1")).resolves.toBe(true);
|
||||
await expect(first.delete("k1")).resolves.toBe(false);
|
||||
await first.register("k1", { value: 1 });
|
||||
await first.clear();
|
||||
await expect(discord.delete("k1")).resolves.toBe(true);
|
||||
await expect(discord.delete("k1")).resolves.toBe(false);
|
||||
await discord.register("k1", { value: 1 });
|
||||
await discord.clear();
|
||||
|
||||
await expect(first.entries()).resolves.toEqual([]);
|
||||
await expect(second.lookup("k2")).resolves.toEqual({ value: 2 });
|
||||
await expect(discord.entries()).resolves.toEqual([]);
|
||||
await expect(telegram.lookup("k2")).resolves.toEqual({ value: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +90,6 @@ describe("plugin state keyed store", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const store = createPluginStateKeyedStore("discord", {
|
||||
namespace: "ttl",
|
||||
maxEntries: 10,
|
||||
defaultTtlMs: 100,
|
||||
});
|
||||
@@ -113,7 +107,7 @@ describe("plugin state keyed store", () => {
|
||||
it("evicts oldest live entries over maxEntries", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-eviction" }, async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "evict", maxEntries: 2 });
|
||||
const store = createPluginStateKeyedStore("discord", { maxEntries: 2 });
|
||||
vi.setSystemTime(1000);
|
||||
await store.register("a", 1);
|
||||
vi.setSystemTime(2000);
|
||||
@@ -125,48 +119,18 @@ describe("plugin state keyed store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the per-plugin live row ceiling would be exceeded without evicting siblings", async () => {
|
||||
it("rejects maxEntries above the plugin-wide live row ceiling", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-plugin-limit" }, async () => {
|
||||
seedPluginStateEntriesForTests([
|
||||
...Array.from({ length: 999 }, (_, entryIndex) => ({
|
||||
pluginId: "discord",
|
||||
namespace: "limit",
|
||||
key: `k-${entryIndex}`,
|
||||
value: { namespaceIndex: 0, entryIndex },
|
||||
})),
|
||||
{
|
||||
pluginId: "discord",
|
||||
namespace: "sibling",
|
||||
key: "k-0",
|
||||
value: { namespaceIndex: 1, entryIndex: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
const limitStore = createPluginStateKeyedStore("discord", {
|
||||
namespace: "limit",
|
||||
maxEntries: 1_001,
|
||||
});
|
||||
const siblingStore = createPluginStateKeyedStore("discord", {
|
||||
namespace: "sibling",
|
||||
maxEntries: 10,
|
||||
});
|
||||
|
||||
await expect(limitStore.register("overflow", { overflow: true })).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
|
||||
});
|
||||
await expect(siblingStore.lookup("k-0")).resolves.toEqual({
|
||||
namespaceIndex: 1,
|
||||
entryIndex: 0,
|
||||
});
|
||||
await expect(limitStore.lookup("overflow")).resolves.toBeUndefined();
|
||||
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 1_001 })).toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("segregates plugins sharing a namespace and key", async () => {
|
||||
it("segregates plugins sharing a key", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-segregation" }, async () => {
|
||||
const discord = createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 10 });
|
||||
const discord = createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
const telegram = createPluginStateKeyedStore("telegram", {
|
||||
namespace: "same",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await discord.register("k", { plugin: "discord" });
|
||||
@@ -178,16 +142,20 @@ describe("plugin state keyed store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("validates namespaces, keys, options, and JSON values before writes", async () => {
|
||||
it("validates keys, options, and JSON values before writes", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-validation" }, async () => {
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("discord", { namespace: "../bad", maxEntries: 10 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("discord", { namespace: "bad-max", maxEntries: 0 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 0 })).toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 1_001 })).toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "valid", maxEntries: 10 });
|
||||
const defaultStore = createPluginStateKeyedStore("default-plugin");
|
||||
await defaultStore.register("default-options", { ok: true });
|
||||
await expect(defaultStore.lookup("default-options")).resolves.toEqual({ ok: true });
|
||||
|
||||
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
await expect(store.register(" ", { ok: true })).rejects.toThrow(PluginStateStoreError);
|
||||
await expect(store.register("undefined", undefined)).rejects.toThrow(PluginStateStoreError);
|
||||
await expect(store.register("infinity", Number.POSITIVE_INFINITY)).rejects.toThrow(
|
||||
@@ -217,11 +185,6 @@ describe("plugin state keyed store", () => {
|
||||
PluginStateStoreError,
|
||||
);
|
||||
|
||||
// Namespace byte-length limit (128 bytes)
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("discord", { namespace: "a".repeat(129), maxEntries: 10 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
|
||||
// JSON depth limit (64 levels)
|
||||
let deep: unknown = { leaf: true };
|
||||
for (let i = 0; i < 65; i += 1) {
|
||||
@@ -243,12 +206,12 @@ describe("plugin state keyed store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects reopening the same namespace with incompatible options", async () => {
|
||||
it("rejects reopening the same plugin store with incompatible options", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-option-consistency" }, async () => {
|
||||
createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 10 });
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 11 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 11 })).toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,20 +219,22 @@ describe("plugin state keyed store", () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-core" }, async () => {
|
||||
const store = createCorePluginStateKeyedStore<{ stopped: boolean }>({
|
||||
ownerId: "core:channel-intent",
|
||||
namespace: "stopped",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("telegram:personal", { stopped: true });
|
||||
await expect(store.lookup("telegram:personal")).resolves.toEqual({ stopped: true });
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("core:not-a-plugin", { namespace: "bad", maxEntries: 10 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
expect(() => createPluginStateKeyedStore("core:not-a-plugin", { maxEntries: 10 })).toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
expect(() => createPluginStateKeyedStore("__proto__", { maxEntries: 10 })).toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the cached DB handle and reopens cleanly", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-close" }, async () => {
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "close", maxEntries: 10 });
|
||||
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
await store.register("k", { ok: true });
|
||||
closePluginStateSqliteStore();
|
||||
await expect(store.lookup("k")).resolves.toEqual({ ok: true });
|
||||
@@ -278,7 +243,7 @@ describe("plugin state keyed store", () => {
|
||||
|
||||
it.runIf(process.platform !== "win32")("hardens DB directory and file permissions", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-permissions" }, async () => {
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "perms", maxEntries: 10 });
|
||||
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
await store.register("k", { ok: true });
|
||||
|
||||
expect(statSync(resolvePluginStateDir()).mode & 0o777).toBe(0o700);
|
||||
@@ -303,7 +268,7 @@ describe("plugin state keyed store", () => {
|
||||
db.exec("PRAGMA user_version = 2;");
|
||||
db.close();
|
||||
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "schema", maxEntries: 10 });
|
||||
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
|
||||
await expect(store.register("k", { ok: true })).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_SCHEMA_UNSUPPORTED",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import {
|
||||
closePluginStateSqliteStore,
|
||||
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
|
||||
MAX_PLUGIN_STATE_VALUE_BYTES,
|
||||
pluginStateClear,
|
||||
pluginStateConsume,
|
||||
@@ -36,7 +38,10 @@ export {
|
||||
const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/iu;
|
||||
const MAX_NAMESPACE_BYTES = 128;
|
||||
const MAX_KEY_BYTES = 512;
|
||||
const MAX_PLUGIN_ID_BYTES = 256;
|
||||
const MAX_JSON_DEPTH = 64;
|
||||
const DEFAULT_PLUGIN_STATE_NAMESPACE = "default";
|
||||
const DEFAULT_PLUGIN_STATE_MAX_ENTRIES = 100;
|
||||
|
||||
type StoreOptionSignature = {
|
||||
maxEntries: number;
|
||||
@@ -85,11 +90,31 @@ function validateKey(value: string, operation: PluginStateStoreOperation = "regi
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validateMaxEntries(value: number): number {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw invalidInput("plugin state maxEntries must be an integer >= 1", "open");
|
||||
function validatePluginStatePluginId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isBlockedObjectKey(trimmed)) {
|
||||
throw invalidInput("plugin state plugin id must not be empty or reserved", "open");
|
||||
}
|
||||
return value;
|
||||
assertMaxBytes("plugin id", trimmed, MAX_PLUGIN_ID_BYTES, "open");
|
||||
if (trimmed.startsWith("core:")) {
|
||||
throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validateMaxEntries(value: number | undefined): number {
|
||||
const resolved = value ?? DEFAULT_PLUGIN_STATE_MAX_ENTRIES;
|
||||
if (
|
||||
!Number.isInteger(resolved) ||
|
||||
resolved < 1 ||
|
||||
resolved > MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN
|
||||
) {
|
||||
throw invalidInput(
|
||||
`plugin state maxEntries must be an integer from 1 to ${MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN}`,
|
||||
"open",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function validateOptionalTtlMs(
|
||||
@@ -202,7 +227,7 @@ function assertConsistentOptions(
|
||||
existing.defaultTtlMs !== signature.defaultTtlMs
|
||||
) {
|
||||
throw invalidInput(
|
||||
`plugin state namespace ${namespace} for ${pluginId} was reopened with incompatible options`,
|
||||
`plugin state store for ${pluginId} was reopened with incompatible options`,
|
||||
"open",
|
||||
);
|
||||
}
|
||||
@@ -210,9 +235,9 @@ function assertConsistentOptions(
|
||||
|
||||
function createKeyedStoreForPluginId<T>(
|
||||
pluginId: string,
|
||||
options: OpenKeyedStoreOptions,
|
||||
options: OpenKeyedStoreOptions = {},
|
||||
): PluginStateKeyedStore<T> {
|
||||
const namespace = validateNamespace(options.namespace);
|
||||
const namespace = validateNamespace(DEFAULT_PLUGIN_STATE_NAMESPACE);
|
||||
const maxEntries = validateMaxEntries(options.maxEntries);
|
||||
const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs);
|
||||
assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs });
|
||||
@@ -256,12 +281,9 @@ function createKeyedStoreForPluginId<T>(
|
||||
|
||||
export function createPluginStateKeyedStore<T>(
|
||||
pluginId: string,
|
||||
options: OpenKeyedStoreOptions,
|
||||
options: OpenKeyedStoreOptions = {},
|
||||
): PluginStateKeyedStore<T> {
|
||||
if (pluginId.startsWith("core:")) {
|
||||
throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open");
|
||||
}
|
||||
return createKeyedStoreForPluginId<T>(pluginId, options);
|
||||
return createKeyedStoreForPluginId<T>(validatePluginStatePluginId(pluginId), options);
|
||||
}
|
||||
|
||||
export function createCorePluginStateKeyedStore<T>(
|
||||
|
||||
@@ -15,8 +15,7 @@ export type PluginStateKeyedStore<T> = {
|
||||
};
|
||||
|
||||
export type OpenKeyedStoreOptions = {
|
||||
namespace: string;
|
||||
maxEntries: number;
|
||||
maxEntries?: number;
|
||||
defaultTtlMs?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
passesManifestOwnerBasePolicy,
|
||||
} from "./manifest-owner-policy.js";
|
||||
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
|
||||
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type {
|
||||
PluginManifestContractListKey,
|
||||
PluginManifestRecord,
|
||||
PluginManifestRegistry,
|
||||
} from "./manifest-registry.js";
|
||||
import {
|
||||
loadPluginRegistrySnapshot,
|
||||
normalizePluginsConfigWithRegistry,
|
||||
@@ -185,7 +189,7 @@ export function resolveExternalAuthProfileProviderPluginIds(params: {
|
||||
}
|
||||
|
||||
function resolveRegistryManifestContractPluginIds(params: {
|
||||
contract: string;
|
||||
contract: PluginManifestContractListKey;
|
||||
config?: PluginLoadOptions["config"];
|
||||
workspaceDir?: string;
|
||||
env?: PluginLoadOptions["env"];
|
||||
@@ -205,10 +209,7 @@ function resolveRegistryManifestContractPluginIds(params: {
|
||||
if (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(plugin.contracts?.[params.contract as keyof NonNullable<typeof plugin.contracts>] ?? [])
|
||||
.length > 0
|
||||
);
|
||||
return (plugin.contracts?.[params.contract]?.length ?? 0) > 0;
|
||||
})
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
@@ -2093,15 +2093,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const baseState = Reflect.get(target, prop, receiver);
|
||||
return {
|
||||
...baseState,
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions): PluginStateKeyedStore<T> => {
|
||||
const record =
|
||||
pluginRuntimeRecordById.get(pluginId) ??
|
||||
registry.plugins.find((entry) => entry.id === pluginId);
|
||||
if (record?.origin !== "bundled") {
|
||||
throw new Error(
|
||||
"openKeyedStore is only available for bundled plugins in this release.",
|
||||
);
|
||||
}
|
||||
openKeyedStore: <T>(options?: OpenKeyedStoreOptions): PluginStateKeyedStore<T> => {
|
||||
return createPluginStateKeyedStore<T>(pluginId, options);
|
||||
},
|
||||
} satisfies PluginRuntime["state"];
|
||||
|
||||
@@ -249,7 +249,7 @@ export type PluginRuntimeCore = {
|
||||
state: {
|
||||
resolveStateDir: typeof import("../../config/paths.js").resolveStateDir;
|
||||
openKeyedStore: <T>(
|
||||
options: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions,
|
||||
options?: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions,
|
||||
) => import("../../plugin-state/plugin-state-store.types.js").PluginStateKeyedStore<T>;
|
||||
};
|
||||
tasks: {
|
||||
|
||||
Reference in New Issue
Block a user