diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index 25ed6a608f4..ff18aaa63bc 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -159,6 +159,19 @@ describe("DiffArtifactStore", () => { }); }); + it("keeps standalone artifact dirs when cleanup overlaps metadata registration", async () => { + const register = blobStore.register.bind(blobStore); + vi.spyOn(blobStore, "register").mockImplementationOnce(async (key, metadata, blob, opts) => { + await store.cleanupExpired(); + await register(key, metadata, blob, opts); + }); + + const standalone = await store.createStandaloneFileArtifact(); + + const directory = await fs.stat(path.dirname(standalone.filePath)); + expect(directory.isDirectory()).toBe(true); + }); + it("expires standalone file artifacts using ttl metadata", async () => { vi.useFakeTimers(); const now = new Date("2026-02-27T16:00:00Z"); diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index f613e2a0893..c68f1f5cbc8 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -168,8 +168,8 @@ export class DiffArtifactStore { ...(params.context ? { context: params.context } : {}), }; - await (await this.artifactRoot()).mkdir(id); await this.writeStandaloneMeta(meta); + await (await this.artifactRoot()).mkdir(id); this.scheduleCleanup(); return { id, diff --git a/src/plugin-state/plugin-state-store.sqlite.ts b/src/plugin-state/plugin-state-store.sqlite.ts index e4134dee816..177737ad033 100644 --- a/src/plugin-state/plugin-state-store.sqlite.ts +++ b/src/plugin-state/plugin-state-store.sqlite.ts @@ -25,6 +25,7 @@ import { } from "./plugin-state-store.types.js"; export const MAX_PLUGIN_STATE_VALUE_BYTES = 65_536; +export const MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN = 1_000; type PluginStateEntriesTable = OpenClawStateKyselyDatabase["plugin_state_entries"]; type PluginStateStoreDatabase = Pick; @@ -247,6 +248,21 @@ function countLivePluginStateNamespaceEntries( return countRow(row); } +function countLivePluginStateEntries( + db: DatabaseSync, + params: { pluginId: string; now: number }, +): number { + const row = executeSqliteQueryTakeFirstSync( + db, + getPluginStateKysely(db) + .selectFrom("plugin_state_entries") + .select((eb) => eb.fn.countAll().as("count")) + .where("plugin_id", "=", params.pluginId) + .where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)])), + ); + return countRow(row); +} + function deleteOldestPluginStateNamespaceEntries( db: DatabaseSync, params: { pluginId: string; namespace: string; protectedKey: string; now: number; limit: number }, @@ -358,6 +374,19 @@ function enforcePostRegisterLimits(params: { limit: namespaceCount - params.maxEntries, }); } + + const pluginCount = countLivePluginStateEntries(params.store.db, { + pluginId: params.pluginId, + now: params.now, + }); + if (pluginCount > MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN) { + throw createPluginStateError({ + code: "PLUGIN_STATE_LIMIT_EXCEEDED", + operation: "register", + message: `Plugin state for ${params.pluginId} exceeds the ${MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN} live row limit.`, + path: params.store.path, + }); + } } export function pluginStateRegister(params: { diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index 6f6eac7d660..da7805bce2a 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -228,7 +228,7 @@ describe("plugin state keyed store", () => { }); }); - it("registerIfAbsent preserves namespace eviction without capping sibling namespaces", async () => { + it("registerIfAbsent preserves sibling namespaces when plugin-wide limit rejects overflow", async () => { await withPluginStateTestState(async () => { vi.useFakeTimers(); const evicting = createPluginStateKeyedStore("discord", { @@ -265,8 +265,11 @@ describe("plugin state keyed store", () => { namespace: "sibling", maxEntries: 10, }); - await expect(limited.registerIfAbsent("overflow", { overflow: true })).resolves.toBe(true); - await expect(limited.lookup("overflow")).resolves.toEqual({ overflow: true }); + await expectPluginStateStoreError(limited.registerIfAbsent("overflow", { overflow: true }), { + code: "PLUGIN_STATE_LIMIT_EXCEEDED", + operation: "register", + }); + await expect(limited.lookup("overflow")).resolves.toBeUndefined(); await expect(sibling.lookup("k-0")).resolves.toEqual({ sibling: true }); }); }); @@ -372,7 +375,7 @@ describe("plugin state keyed store", () => { }); }); - it("applies entry limits per namespace without evicting siblings", async () => { + it("rejects plugin-wide overflow without evicting sibling namespaces", async () => { await withPluginStateTestState(async () => { seedPluginStateEntriesForTests([ ...Array.from({ length: 5_989 }, (_, entryIndex) => ({ @@ -398,12 +401,15 @@ describe("plugin state keyed store", () => { maxEntries: 100, }); - await expect(limitStore.register("overflow", { overflow: true })).resolves.toBeUndefined(); + await expectPluginStateStoreError(limitStore.register("overflow", { overflow: true }), { + code: "PLUGIN_STATE_LIMIT_EXCEEDED", + operation: "register", + }); await expect(siblingStore.lookup("k-0")).resolves.toEqual({ namespaceIndex: 1, entryIndex: 0, }); - await expect(limitStore.lookup("overflow")).resolves.toEqual({ overflow: true }); + await expect(limitStore.lookup("overflow")).resolves.toBeUndefined(); }); });