diff --git a/index.ts b/index.ts index a4b476f1..7e82265b 100644 --- a/index.ts +++ b/index.ts @@ -20,7 +20,7 @@ import { spawn } from "node:child_process"; const isCliMode = () => process.env.OPENCLAW_CLI === "1"; // Import core components -import { MemoryStore, normalizeStoragePath, validateStoragePath, type MemoryEntry } from "./src/store.js"; +import { MemoryStore, normalizeStoragePath, type MemoryEntry } from "./src/store.js"; import { createEmbedder, getEffectiveVectorDimensions, @@ -1991,25 +1991,20 @@ interface PluginSingletonState { let _singletonState: PluginSingletonState | null = null; -function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { - const config = parsePluginConfig(api.pluginConfig); - let resolvedDbPath = normalizeStoragePath(api.resolvePath(config.dbPath || getDefaultDbPath())); - - try { - resolvedDbPath = validateStoragePath(resolvedDbPath); - } catch (err) { - api.logger.warn( - `memory-lancedb-pro: storage path issue — ${String(err)}\n` + - ` The plugin will still attempt to start, but writes may fail.`, - ); - } - - const vectorDim = getEffectiveVectorDimensions( - config.embedding.model || "text-embedding-3-small", - config.embedding.dimensions, - config.embedding.requestDimensions, - ); - const store = new MemoryStore({ dbPath: resolvedDbPath, vectorDim }); +function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { + const config = parsePluginConfig(api.pluginConfig); + let resolvedDbPath = normalizeStoragePath(api.resolvePath(config.dbPath || getDefaultDbPath())); + + const vectorDim = getEffectiveVectorDimensions( + config.embedding.model || "text-embedding-3-small", + config.embedding.dimensions, + config.embedding.requestDimensions, + ); + const store = new MemoryStore({ + dbPath: resolvedDbPath, + vectorDim, + onStoragePathWarning: (message) => api.logger.warn(message), + }); const embedder = createEmbedder({ provider: "openai-compatible", apiKey: config.embedding.apiKey, diff --git a/src/store.ts b/src/store.ts index b84b5968..337b8046 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,6 +16,12 @@ import { unlinkSync, rmdirSync, } from "node:fs"; +import { + access as accessAsync, + lstat as lstatAsync, + mkdir as mkdirAsync, + realpath as realpathAsync, +} from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSmartMetadata, isMemoryActiveAt, parseSmartMetadata, stringifySmartMetadata } from "./smart-metadata.js"; @@ -43,6 +49,7 @@ export interface MemorySearchResult { export interface StoreConfig { dbPath: string; vectorDim: number; + onStoragePathWarning?: (message: string) => void; } export interface MetadataPatch { @@ -235,6 +242,78 @@ export function validateStoragePath(dbPath: string): string { return resolvedPath; } +/** + * Async variant of {@link validateStoragePath}. Use this on runtime paths so + * slow filesystems do not block OpenClaw's event loop during startup. + */ +export async function validateStoragePathAsync(dbPath: string): Promise { + let resolvedPath = normalizeStoragePath(dbPath); + + // Resolve symlinks (including dangling symlinks) + try { + const stats = await lstatAsync(dbPath); + if (stats.isSymbolicLink()) { + try { + resolvedPath = await realpathAsync(dbPath); + } catch (err: any) { + throw new Error( + `dbPath "${dbPath}" is a symlink whose target does not exist.\n` + + ` Fix: Create the target directory, or update the symlink to point to a valid path.\n` + + ` Details: ${err.code || ""} ${err.message}`, + ); + } + } + } catch (err: any) { + // Missing path is OK (it will be created below) + if (err?.code === "ENOENT") { + // no-op + } else if ( + typeof err?.message === "string" && + err.message.includes("symlink whose target does not exist") + ) { + throw err; + } else { + // Other lstat failures — continue with original path + } + } + + // Create directory if it doesn't exist + let pathExists = false; + try { + await accessAsync(resolvedPath, constants.F_OK); + pathExists = true; + } catch { + pathExists = false; + } + + if (!pathExists) { + try { + await mkdirAsync(resolvedPath, { recursive: true }); + } catch (err: any) { + throw new Error( + `Failed to create dbPath directory "${resolvedPath}".\n` + + ` Fix: Ensure the parent directory "${dirname(resolvedPath)}" exists and is writable,\n` + + ` or create it manually: mkdir -p "${resolvedPath}"\n` + + ` Details: ${err.code || ""} ${err.message}`, + ); + } + } + + // Check write permissions + try { + await accessAsync(resolvedPath, constants.W_OK); + } catch (err: any) { + throw new Error( + `dbPath directory "${resolvedPath}" is not writable.\n` + + ` Fix: Check permissions with: ls -la "${dirname(resolvedPath)}"\n` + + ` Or grant write access: chmod u+w "${resolvedPath}"\n` + + ` Details: ${err.code || ""} ${err.message}`, + ); + } + + return resolvedPath; +} + // ============================================================================ // Memory Store // ============================================================================ @@ -420,6 +499,15 @@ export class MemoryStore { } private async doInitialize(): Promise { + try { + this.config.dbPath = await validateStoragePathAsync(this.config.dbPath); + } catch (err) { + this.config.onStoragePathWarning?.( + `memory-lancedb-pro: storage path issue — ${String(err)}\n` + + ` The plugin will still attempt to start, but writes may fail.`, + ); + } + const lancedb = await loadLanceDB(); let db: LanceDB.Connection; diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs index 8588e99b..66a6e248 100644 --- a/test/plugin-manifest-regression.mjs +++ b/test/plugin-manifest-regression.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import http from "node:http"; import Module from "node:module"; import { tmpdir } from "node:os"; @@ -150,9 +150,10 @@ const services = []; const embeddingRequests = []; try { + const startupDbPath = path.join(workDir, "db"); const api = createMockApi( { - dbPath: path.join(workDir, "db"), + dbPath: startupDbPath, autoRecall: false, embedding: { provider: "openai-compatible", @@ -165,7 +166,13 @@ try { { services }, ); resetRegistration(); + assert.equal(existsSync(startupDbPath), false, "test dbPath should start missing"); plugin.register(api); + assert.equal( + existsSync(startupDbPath), + false, + "plugin registration should not synchronously create or validate dbPath", + ); assert.equal(services.length, 1, "plugin should register its background service"); assert.equal(typeof api.hooks.agent_end, "function", "autoCapture should remain enabled by default"); assert.equal(typeof api.hooks["command:new"], "function", "selfImprovement command:new hook should be registered by default (#391)"); diff --git a/test/storage-path-normalization.test.mjs b/test/storage-path-normalization.test.mjs index 2cb593c6..33df481c 100644 --- a/test/storage-path-normalization.test.mjs +++ b/test/storage-path-normalization.test.mjs @@ -7,7 +7,12 @@ import { pathToFileURL } from "node:url"; import jitiFactory from "jiti"; const jiti = jitiFactory(import.meta.url, { interopDefault: true }); -const { MemoryStore, normalizeStoragePath, validateStoragePath } = jiti("../src/store.ts"); +const { + MemoryStore, + normalizeStoragePath, + validateStoragePath, + validateStoragePathAsync, +} = jiti("../src/store.ts"); describe("storage path normalization", () => { it("converts Windows drive-letter file URLs to native paths", () => { @@ -49,4 +54,13 @@ describe("storage path normalization", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("validates local file URLs asynchronously using native filesystem paths", async () => { + const dir = mkdtempSync(join(tmpdir(), "memory-lancedb-pro-file-url-async-")); + try { + assert.equal(await validateStoragePathAsync(pathToFileURL(dir).href), dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); });