diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d201f..1ed4b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Mock the current plugin blob-store runtime so Diffs and other trusted plugins can be captured without persistent state. - Parse OpenClaw hook names after their source declaration became private and added a TypeScript `satisfies` constraint. - Run synthetic gateway lifecycle hooks around ordinary probes while preserving capture indexes and report order, so `gateway_stop` teardown cannot invalidate later compatibility checks. - Link isolated plugin workspaces to the OpenClaw checkout without npm normalizing duplicated dependency and peer declarations back to registry ranges. diff --git a/src/capture-api.js b/src/capture-api.js index 85eb475..d154f2b 100644 --- a/src/capture-api.js +++ b/src/capture-api.js @@ -194,6 +194,7 @@ function registrationReturnValue(name, args, context) { function createRuntimeContext(options) { const runtime = options.runtime ?? {}; + const blobStores = new Map(); const syncKeyedStores = new Map(); return { ...runtime, @@ -204,6 +205,15 @@ function createRuntimeContext(options) { tts: runtime.tts ?? {}, state: { resolveStateDir: () => options.stateDir ?? process.cwd(), + openBlobStore(storeOptions) { + const existing = blobStores.get(storeOptions.namespace); + if (existing) { + return existing; + } + const store = createBlobStoreContext(storeOptions); + blobStores.set(storeOptions.namespace, store); + return store; + }, openSyncKeyedStore({ namespace }) { const existing = syncKeyedStores.get(namespace); if (existing) { @@ -218,6 +228,85 @@ function createRuntimeContext(options) { }; } +function createBlobStoreContext(options) { + const values = new Map(); + const entryInfo = (key, entry) => ({ + key, + metadata: entry.metadata, + sizeBytes: entry.bytes.byteLength, + createdAt: entry.createdAt, + ...(entry.expiresAt === undefined ? {} : { expiresAt: entry.expiresAt }), + }); + const read = (key) => { + const entry = values.get(key); + if (!entry) { + return undefined; + } + if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) { + return undefined; + } + return { ...entryInfo(key, entry), bytes: Uint8Array.from(entry.bytes) }; + }; + const register = async (key, bytes, metadata, registerOptions) => { + const createdAt = Date.now(); + const ttlMs = registerOptions?.ttlMs ?? options.defaultTtlMs; + values.set(key, { + bytes: Uint8Array.from(bytes), + metadata, + createdAt, + ...(ttlMs === undefined ? {} : { expiresAt: createdAt + ttlMs }), + }); + }; + return { + register, + async registerIfAbsent(key, bytes, metadata, registerOptions) { + if (values.has(key)) { + return false; + } + await register(key, bytes, metadata, registerOptions); + return true; + }, + async lookup(key) { + return read(key); + }, + async entries() { + return [...values.keys()].flatMap((key) => { + const entry = read(key); + if (!entry) { + return []; + } + const { bytes: _bytes, ...info } = entry; + return [info]; + }); + }, + async delete(key) { + return values.delete(key); + }, + async deleteExpiredKey(key) { + const entry = values.get(key); + if (!entry || entry.expiresAt === undefined || entry.expiresAt > Date.now()) { + return undefined; + } + values.delete(key); + return entryInfo(key, entry); + }, + async deleteExpired() { + const expired = []; + for (const [key, entry] of values) { + if (entry.expiresAt === undefined || entry.expiresAt > Date.now()) { + continue; + } + values.delete(key); + expired.push(entryInfo(key, entry)); + } + return expired; + }, + async clear() { + values.clear(); + }, + }; +} + function createSyncKeyedStoreContext() { const values = new Map(); return { diff --git a/test/capture-api.test.js b/test/capture-api.test.js index 3d06f4c..98bbcf0 100644 --- a/test/capture-api.test.js +++ b/test/capture-api.test.js @@ -88,7 +88,16 @@ test("capture API exposes mock context helpers", async () => { namespace: "capture-test", maxEntries: 10, }); + const blobStore = api.runtime.state.openBlobStore({ + namespace: "capture-blobs", + maxEntries: 10, + maxBytesPerEntry: 1_024, + maxBytesPerNamespace: 4_096, + }); + const sourceBytes = Buffer.from([1, 2, 3]); keyedStore.register("key", { value: 2 }); + assert.equal(await blobStore.registerIfAbsent("artifact", sourceBytes, { kind: "diff" }), true); + sourceBytes[0] = 9; assert.equal(await api.secrets.get("token"), "redacted"); assert.equal(await api.secrets.resolve("secret:token"), "redacted"); @@ -98,6 +107,42 @@ test("capture API exposes mock context helpers", async () => { assert.equal(api.paths.dataDir, ".plugin-inspector/data"); assert.equal(api.resolvePath("state"), "/fixture/state"); assert.deepEqual(keyedStore.lookup("key"), { value: 2 }); + const firstLookup = await blobStore.lookup("artifact"); + assert.deepEqual([...(firstLookup?.bytes ?? [])], [1, 2, 3]); + firstLookup.bytes[1] = 9; + assert.deepEqual([...((await blobStore.lookup("artifact"))?.bytes ?? [])], [1, 2, 3]); + assert.deepEqual((await blobStore.entries()).map(({ key, metadata, sizeBytes }) => ({ key, metadata, sizeBytes })), [ + { key: "artifact", metadata: { kind: "diff" }, sizeBytes: 3 }, + ]); + assert.equal(await blobStore.registerIfAbsent("artifact", new Uint8Array([4]), { kind: "other" }), false); + const originalDateNow = Date.now; + let now = 1_000; + Date.now = () => now; + try { + assert.equal( + await blobStore.registerIfAbsent("expiring", new Uint8Array([5]), { kind: "old" }, { ttlMs: 10 }), + true, + ); + now = 1_011; + assert.equal(await blobStore.lookup("expiring"), undefined); + assert.equal( + await blobStore.registerIfAbsent("expiring", new Uint8Array([6]), { kind: "new" }), + false, + ); + assert.deepEqual(await blobStore.deleteExpiredKey("expiring"), { + key: "expiring", + metadata: { kind: "old" }, + sizeBytes: 1, + createdAt: 1_000, + expiresAt: 1_010, + }); + assert.equal( + await blobStore.registerIfAbsent("expiring", new Uint8Array([6]), { kind: "new" }), + true, + ); + } finally { + Date.now = originalDateNow; + } assert.equal(keyedStore.update("key", () => undefined), false); assert.deepEqual(keyedStore.lookup("key"), { value: 2 }); assert.equal( diff --git a/test/runtime-capture-report.test.js b/test/runtime-capture-report.test.js index c12fb18..1454f14 100644 --- a/test/runtime-capture-report.test.js +++ b/test/runtime-capture-report.test.js @@ -256,6 +256,8 @@ test("runtime capture supports TypeScript entrypoints, SDK subpaths, external mo "reexportedSdkHelper?.();", "export default definePluginEntry((api) => {", " api.runtime.state.resolveStateDir();", + " const blobStore = api.runtime.state.openBlobStore({ namespace: 'artifacts', maxEntries: 10, maxBytesPerEntry: 1024, maxBytesPerNamespace: 4096 });", + " void blobStore.lookup('viewer');", " const stateStore = api.runtime.state.openSyncKeyedStore({ namespace: 'weather', maxEntries: 10 });", " stateStore.register('forecast', { value: 'sunny' });", " stateStore.lookup('forecast');",