UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

389 lines (388 loc) 14.1 kB
import { n as resolveNonNegativeIntegerOption } from "./numeric-options-BuRt1hwI.js"; import { t as createDedupeCache } from "./dedupe-DnzL4okR.js"; import { r as createPluginStateSyncKeyedStore, t as createCorePluginStateSyncKeyedStore } from "./plugin-state-store-jd1pQXxt.js"; import fs from "node:fs/promises"; import { createHash } from "node:crypto"; //#region src/plugin-sdk/persistent-dedupe.ts const LEGACY_PATH_OWNER_ID = "core:persistent-dedupe"; const DEFAULT_NAMESPACE_PREFIX = "persistent-dedupe"; function resolveNamespace(namespace) { return namespace?.trim() || "global"; } function resolveScopedKey(namespace, key) { return `${namespace}:${key}`; } function isRecentTimestamp(seenAt, ttlMs, now) { return seenAt != null && (ttlMs <= 0 || now - seenAt < ttlMs); } function resolveEntrySeenAt(entry) { return typeof entry?.seenAt === "number" && Number.isFinite(entry.seenAt) ? entry.seenAt : void 0; } function resolveUnknownEntrySeenAt(value) { if (!value || typeof value !== "object" || !("seenAt" in value)) return; return typeof value.seenAt === "number" && Number.isFinite(value.seenAt) ? value.seenAt : void 0; } function shortHash(value) { return createHash("sha256").update(value).digest("hex").slice(0, 32); } function resolveEntryKey(key) { return `k.${shortHash(key)}`; } function createPersistentDedupeImportEntry(params) { return { key: resolveEntryKey(params.key), value: { key: params.key, seenAt: params.seenAt }, ...params.ttlMs != null ? { ttlMs: params.ttlMs } : {} }; } function resolveRemainingTtlMs(seenAt, ttlMs, now) { if (ttlMs <= 0) return; const remaining = ttlMs - (now - seenAt); return remaining > 0 ? { ttlMs: Math.max(1, Math.floor(remaining)) } : null; } function normalizeNamespacePrefix(value) { return (value ?? DEFAULT_NAMESPACE_PREFIX).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "").slice(0, 48) || DEFAULT_NAMESPACE_PREFIX; } function resolveStateNamespace(prefix, namespace) { return `${prefix}.${shortHash(namespace)}`; } function resolvePersistentDedupePluginStateNamespace(options) { return resolveStateNamespace(normalizeNamespacePrefix(options.namespacePrefix), resolveNamespace(options.namespace)); } function hasPluginStateOptions(options) { return typeof options.pluginId === "string"; } function hasLegacyPathOptions(options) { return typeof options.resolveFilePath === "function"; } function resolveStateMaxEntries(options) { const maxEntries = hasPluginStateOptions(options) ? options.stateMaxEntries : options.fileMaxEntries; return Math.max(1, resolveNonNegativeIntegerOption(maxEntries, 1)); } function resolvePersistentStoreCacheKey(pluginId, namespace) { return `${pluginId}\0${namespace}`; } function createPersistentStoreResolver(options) { const maxEntries = resolveStateMaxEntries(options); const ttlMs = resolveNonNegativeIntegerOption(options.ttlMs, 0); const defaultTtlMs = ttlMs > 0 ? ttlMs : void 0; const stores = /* @__PURE__ */ new Map(); if (hasPluginStateOptions(options)) { const pluginId = options.pluginId; const prefix = normalizeNamespacePrefix(options.namespacePrefix); return (namespace) => { const stateNamespace = resolveStateNamespace(prefix, namespace); const cacheKey = resolvePersistentStoreCacheKey(pluginId, stateNamespace); const existing = stores.get(cacheKey); if (existing) return existing; const store = createPluginStateSyncKeyedStore(pluginId, { namespace: stateNamespace, maxEntries, ...defaultTtlMs != null ? { defaultTtlMs } : {}, ...options.env ? { env: options.env } : {} }); stores.set(cacheKey, store); return store; }; } const prefix = normalizeNamespacePrefix("legacy-path"); return (namespace) => { const stateNamespace = resolveStateNamespace(prefix, options.resolveFilePath(namespace)); const cacheKey = resolvePersistentStoreCacheKey(LEGACY_PATH_OWNER_ID, stateNamespace); const existing = stores.get(cacheKey); if (existing) return existing; const store = createCorePluginStateSyncKeyedStore({ ownerId: LEGACY_PATH_OWNER_ID, namespace: stateNamespace, maxEntries, ...defaultTtlMs != null ? { defaultTtlMs } : {}, ...options.env ? { env: options.env } : {} }); stores.set(cacheKey, store); return store; }; } function parseLegacyDedupeData(raw) { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { data: {}, invalidCount: 0 }; const data = {}; let invalidCount = 0; for (const [key, seenAt] of Object.entries(parsed)) if (typeof seenAt === "number" && Number.isFinite(seenAt) && seenAt > 0) data[key] = seenAt; else invalidCount++; return { data, invalidCount }; } async function readPersistentDedupeLegacyJsonFileEntries(options) { const { data, invalidCount } = parseLegacyDedupeData(await fs.readFile(options.filePath, "utf8")); const ttlMs = resolveNonNegativeIntegerOption(options.ttlMs, 0); const now = options.now ?? Date.now(); const entries = []; let skippedExpired = 0; for (const [key, seenAt] of Object.entries(data)) { const ttlOption = resolveRemainingTtlMs(seenAt, ttlMs, now); if (ttlOption === null) { skippedExpired++; continue; } entries.push(createPersistentDedupeImportEntry({ key, seenAt, ...ttlOption })); } return { entries, skippedExpired, skippedInvalid: invalidCount }; } async function listPersistentDedupeLegacyJsonFileEntries(options) { return (await readPersistentDedupeLegacyJsonFileEntries(options)).entries; } function shouldReplacePersistentDedupeEntry(params) { const incomingSeenAt = resolveUnknownEntrySeenAt(params.incomingValue); return incomingSeenAt != null && incomingSeenAt > (resolveUnknownEntrySeenAt(params.existingValue) ?? 0); } /** Import one retired JSON dedupe cache file into plugin-state SQLite during doctor repair. */ async function migratePersistentDedupeLegacyJsonFile(options) { const legacy = await readPersistentDedupeLegacyJsonFileEntries(options); const store = createPersistentStoreResolver(options)(resolveNamespace(options.namespace)); const result = { imported: 0, skippedExpired: legacy.skippedExpired, skippedInvalid: legacy.skippedInvalid, skippedExisting: 0, removed: false }; for (const entry of legacy.entries) if (store.update?.(entry.key, (current) => { const currentSeenAt = resolveEntrySeenAt(current); if (currentSeenAt != null && currentSeenAt >= entry.value.seenAt) return; return entry.value; }, entry.ttlMs != null ? { ttlMs: entry.ttlMs } : void 0)) result.imported++; else result.skippedExisting++; if (options.removeFile !== false) { await fs.rm(options.filePath, { force: true }); result.removed = true; } return result; } /** Create a dedupe helper that combines in-memory fast checks with SQLite-backed state. */ function createPersistentDedupe(options) { const ttlMs = resolveNonNegativeIntegerOption(options.ttlMs, 0); const memoryMaxSize = resolveNonNegativeIntegerOption(options.memoryMaxSize, 0); const getStore = createPersistentStoreResolver(options); const memory = createDedupeCache({ ttlMs, maxSize: memoryMaxSize }); const inflight = /* @__PURE__ */ new Map(); async function checkAndRecordInner(key, namespace, scopedKey, now, onDiskError) { if (memory.check(scopedKey, now)) return false; try { const entryKey = resolveEntryKey(key); const store = getStore(namespace); let duplicateSeenAt; store.update?.(entryKey, (entry) => { const seenAt = resolveEntrySeenAt(entry); if (isRecentTimestamp(seenAt, ttlMs, now)) { duplicateSeenAt = seenAt; return; } return { key, seenAt: now }; }, ttlMs > 0 ? { ttlMs } : void 0); if (duplicateSeenAt != null) { memory.check(scopedKey, duplicateSeenAt); return false; } memory.check(scopedKey, now); return true; } catch (error) { onDiskError?.(error); memory.check(scopedKey, now); return true; } } async function hasRecentInner(key, namespace, scopedKey, now, onDiskError) { if (memory.peek(scopedKey, now)) return true; try { const seenAt = resolveEntrySeenAt(getStore(namespace).lookup(resolveEntryKey(key))); if (!isRecentTimestamp(seenAt, ttlMs, now)) return false; memory.check(scopedKey, seenAt); return true; } catch (error) { onDiskError?.(error); return memory.peek(scopedKey, now); } } async function warmup(namespace = "global", onError) { const now = Date.now(); try { let loaded = 0; for (const entry of getStore(resolveNamespace(namespace)).entries()) { const ts = resolveEntrySeenAt(entry.value); if (ts == null) continue; if (ttlMs > 0 && now - ts >= ttlMs) continue; const scopedKey = `${resolveNamespace(namespace)}:${entry.value.key}`; memory.check(scopedKey, ts); loaded++; } return loaded; } catch (error) { onError?.(error); return 0; } } async function checkAndRecord(key, dedupeOptions) { const trimmed = key.trim(); if (!trimmed) return true; const namespace = resolveNamespace(dedupeOptions?.namespace); const scopedKey = resolveScopedKey(namespace, trimmed); if (inflight.has(scopedKey)) return false; const onDiskError = dedupeOptions?.onDiskError ?? options.onDiskError; const work = checkAndRecordInner(trimmed, namespace, scopedKey, dedupeOptions?.now ?? Date.now(), onDiskError); inflight.set(scopedKey, work); try { return await work; } finally { inflight.delete(scopedKey); } } async function hasRecent(key, dedupeOptions) { const trimmed = key.trim(); if (!trimmed) return false; const namespace = resolveNamespace(dedupeOptions?.namespace); const scopedKey = resolveScopedKey(namespace, trimmed); const onDiskError = dedupeOptions?.onDiskError ?? options.onDiskError; return hasRecentInner(trimmed, namespace, scopedKey, dedupeOptions?.now ?? Date.now(), onDiskError); } return { checkAndRecord, hasRecent, warmup, clearMemory: () => memory.clear(), memorySize: () => memory.size() }; } function createReleasedClaimError(scopedKey) { return /* @__PURE__ */ new Error(`claim released before commit: ${scopedKey}`); } /** Create a claim/commit/release dedupe guard backed by memory and optional persistent storage. */ function createClaimableDedupe(options) { const ttlMs = resolveNonNegativeIntegerOption(options.ttlMs, 0); const memoryMaxSize = resolveNonNegativeIntegerOption(options.memoryMaxSize, 0); const memory = createDedupeCache({ ttlMs, maxSize: memoryMaxSize }); let persistent = null; if (hasPluginStateOptions(options)) persistent = createPersistentDedupe({ ttlMs, memoryMaxSize, pluginId: options.pluginId, stateMaxEntries: Math.max(1, resolveNonNegativeIntegerOption(options.stateMaxEntries, 1)), ...options.namespacePrefix ? { namespacePrefix: options.namespacePrefix } : {}, ...options.env ? { env: options.env } : {}, ...options.onDiskError ? { onDiskError: options.onDiskError } : {} }); else if (hasLegacyPathOptions(options)) persistent = createPersistentDedupe({ ttlMs, memoryMaxSize, fileMaxEntries: Math.max(1, resolveNonNegativeIntegerOption(options.fileMaxEntries, 1)), resolveFilePath: options.resolveFilePath, ...options.env ? { env: options.env } : {}, ...options.lockOptions ? { lockOptions: options.lockOptions } : {}, ...options.onDiskError ? { onDiskError: options.onDiskError } : {} }); const inflight = /* @__PURE__ */ new Map(); async function hasRecent(key, dedupeOptions) { const trimmed = key.trim(); if (!trimmed) return false; const scopedKey = resolveScopedKey(resolveNamespace(dedupeOptions?.namespace), trimmed); if (persistent) return persistent.hasRecent(trimmed, dedupeOptions); return memory.peek(scopedKey, dedupeOptions?.now); } async function claim(key, dedupeOptions) { const trimmed = key.trim(); if (!trimmed) return { kind: "claimed" }; const scopedKey = resolveScopedKey(resolveNamespace(dedupeOptions?.namespace), trimmed); const existing = inflight.get(scopedKey); if (existing) return { kind: "inflight", pending: existing.promise }; let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); promise.catch(() => {}); inflight.set(scopedKey, { promise, resolve, reject }); try { if (await hasRecent(trimmed, dedupeOptions)) { resolve(false); inflight.delete(scopedKey); return { kind: "duplicate" }; } return { kind: "claimed" }; } catch (error) { reject(error); inflight.delete(scopedKey); throw error; } } async function commit(key, dedupeOptions) { const trimmed = key.trim(); if (!trimmed) return true; const scopedKey = resolveScopedKey(resolveNamespace(dedupeOptions?.namespace), trimmed); const claimValue = inflight.get(scopedKey); try { const recorded = persistent ? await persistent.checkAndRecord(trimmed, dedupeOptions) : !memory.check(scopedKey, dedupeOptions?.now); claimValue?.resolve(recorded); return recorded; } catch (error) { claimValue?.reject(error); throw error; } finally { inflight.delete(scopedKey); } } function release(key, dedupeOptions) { const trimmed = key.trim(); if (!trimmed) return; const scopedKey = resolveScopedKey(resolveNamespace(dedupeOptions?.namespace), trimmed); const claimLocal = inflight.get(scopedKey); if (!claimLocal) return; claimLocal.reject(dedupeOptions?.error ?? createReleasedClaimError(scopedKey)); inflight.delete(scopedKey); } return { claim, commit, release, hasRecent, warmup: persistent?.warmup ?? (async () => 0), clearMemory: () => { persistent?.clearMemory(); memory.clear(); }, memorySize: () => persistent?.memorySize() ?? memory.size() }; } //#endregion export { migratePersistentDedupeLegacyJsonFile as a, listPersistentDedupeLegacyJsonFileEntries as i, createPersistentDedupe as n, resolvePersistentDedupePluginStateNamespace as o, createPersistentDedupeImportEntry as r, shouldReplacePersistentDedupeEntry as s, createClaimableDedupe as t };