diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index e75ae85d66ad..6e9f6feaa298 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2411,7 +2411,6 @@ src/commands/setup.ts 1 src/commands/status-all/channels.ts 2 src/commands/status-all/gateway.ts 1 src/commands/status.scan.bootstrap-shared.ts 1 -src/commands/status.scan.shared.ts 1 src/commands/system-agent-with-inference.ts 2 src/commands/tasks-json.ts 2 src/commands/tasks.ts 2 diff --git a/docs/reference/database-schemas/storage-changes.md b/docs/reference/database-schemas/storage-changes.md index 83e7810c361a..30f0937fa1ee 100644 --- a/docs/reference/database-schemas/storage-changes.md +++ b/docs/reference/database-schemas/storage-changes.md @@ -212,6 +212,14 @@ Only pending reads coalesce; completed results are not cached. Physical integrit verification remains with full registry restoration and Doctor, while known database failures and quarantine still refuse summary reads. +Offline `status --json --all` checks for existing built-in memory data through +memory-core's retrieval worker before constructing a memory manager. The check +retains current and shipped table recognition, missing-store behavior, and +best-effort read failures without creating or migrating a database. Custom memory +slots and explicitly configured memory retain their existing selection paths. +This moves only the presence check; memory-manager diagnostics keep their own +lifecycle and execution contracts. + Gateway, embedded, and TUI session lists use resident materialized rows and the subagent registry's owner-maintained memory snapshot. Each durable session store is hydrated when first admitted, replaced, or reintroduced; departing stores lose diff --git a/extensions/memory-core/api.ts b/extensions/memory-core/api.ts index ad7bb39b6c58..a1eaae58794c 100644 --- a/extensions/memory-core/api.ts +++ b/extensions/memory-core/api.ts @@ -17,6 +17,7 @@ export type { PreviewRemHarnessOptions, PreviewRemHarnessResult } from "./src/re export { configureMemoryCoreDreamingState } from "./src/dreaming-state.js"; export { filterMemorySearchHitsBySessionVisibility } from "./src/session-search-visibility.js"; export { captureMemoryRebuildNotice } from "./src/memory-rebuild-notice.js"; +export { inspectMemoryIndexPresence } from "./src/memory/manager-status-presence.runtime.js"; export { MEMORY_MANAGED_LOCAL_EMBEDDING_SETUP_CHECK_ID, pluginStateIsolatedDoctorCheckIds, diff --git a/extensions/memory-core/src/memory/manager-cpu-worker-runtime.ts b/extensions/memory-core/src/memory/manager-cpu-worker-runtime.ts index d9edbe885d83..44827bee60b4 100644 --- a/extensions/memory-core/src/memory/manager-cpu-worker-runtime.ts +++ b/extensions/memory-core/src/memory/manager-cpu-worker-runtime.ts @@ -34,6 +34,18 @@ const indexing = new WorkerTaskPool({ type MemoryReadTarget = { databasePath: string; agentId: string }; +export async function runMemoryPresenceInspection(databasePath: string): Promise { + ensureSqliteLibrarySelected(); + const result = await retrieval.run( + { kind: "presence", databasePath }, + { inputBytes: databasePath.length * 2 }, + ); + if (result.kind !== "presence") { + throw new Error("Invalid memory presence worker result"); + } + return result.present; +} + export async function runMemoryKeywordSearch( target: MemoryReadTarget, query: MemoryKeywordWorkerQuery, diff --git a/extensions/memory-core/src/memory/manager-search.worker.ts b/extensions/memory-core/src/memory/manager-search.worker.ts index 27e25bb3e395..9bfbd7843134 100644 --- a/extensions/memory-core/src/memory/manager-search.worker.ts +++ b/extensions/memory-core/src/memory/manager-search.worker.ts @@ -3,6 +3,7 @@ import { openOpenClawAgentDatabaseReadOnly } from "openclaw/plugin-sdk/memory-co import { serveWorkerTasks } from "openclaw/plugin-sdk/process-runtime"; import { bm25RankToScore, buildFtsQuery } from "./keyword-query.js"; import { searchChunksByEmbedding, searchKeyword, searchPathKeyword } from "./manager-search.js"; +import { inspectMemoryIndexPresenceInWorker } from "./manager-status-presence.js"; type KeywordParameters = Omit< Parameters[0], @@ -17,15 +18,15 @@ export type MemoryVectorWorkerQuery = Omit< Parameters[0], "db" | "signal" >; -export type MemorySearchWorkerInput = { - databasePath: string; - agentId: string; -} & ( - | { kind: "keyword"; query: MemoryKeywordWorkerQuery } - | { kind: "vector"; query: MemoryVectorWorkerQuery } -); +export type MemorySearchWorkerInput = + | { kind: "presence"; databasePath: string } + | ({ databasePath: string; agentId: string } & ( + | { kind: "keyword"; query: MemoryKeywordWorkerQuery } + | { kind: "vector"; query: MemoryVectorWorkerQuery } + )); type QueryResult = { rows: T; error?: string }; export type MemorySearchWorkerOutput = + | { kind: "presence"; present: boolean } | { kind: "keyword"; body: QueryResult>>; @@ -34,8 +35,12 @@ export type MemorySearchWorkerOutput = | { kind: "vector"; rows: Awaited> }; serveWorkerTasks(async (input): Promise => { - // SAFETY: The paired runtime constructs the request; the canonical reader validates the database owner. + // SAFETY: The paired runtime constructs the private request union. const request = input as MemorySearchWorkerInput; + if (request.kind === "presence") { + // This pre-manager probe also recognizes shipped memory-only databases. + return { kind: "presence", present: inspectMemoryIndexPresenceInWorker(request.databasePath) }; + } const opened = openOpenClawAgentDatabaseReadOnly({ agentId: request.agentId, path: request.databasePath, diff --git a/extensions/memory-core/src/memory/manager-status-presence.runtime.ts b/extensions/memory-core/src/memory/manager-status-presence.runtime.ts new file mode 100644 index 000000000000..d9fed3984690 --- /dev/null +++ b/extensions/memory-core/src/memory/manager-status-presence.runtime.ts @@ -0,0 +1,8 @@ +export async function inspectMemoryIndexPresence(databasePath: string): Promise { + const { runMemoryPresenceInspection } = await import("./manager-cpu-worker-runtime.js"); + try { + return await runMemoryPresenceInspection(databasePath); + } catch { + return false; + } +} diff --git a/extensions/memory-core/src/memory/manager-status-presence.ts b/extensions/memory-core/src/memory/manager-status-presence.ts new file mode 100644 index 000000000000..bd7af961d408 --- /dev/null +++ b/extensions/memory-core/src/memory/manager-status-presence.ts @@ -0,0 +1,64 @@ +import type { DatabaseSync } from "node:sqlite"; +import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/memory-core-host-engine-knn"; +import { + MEMORY_INDEX_CHUNKS_TABLE, + MEMORY_INDEX_META_TABLE, + MEMORY_INDEX_SOURCES_TABLE, +} from "openclaw/plugin-sdk/memory-core-host-engine-schema"; + +const MEMORY_INDEX_META_KEY = "memory_index_meta_v1"; + +/** Inspect existing memory tables without constructing or migrating a manager. */ +export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolean { + let db: DatabaseSync | undefined; + try { + db = openNodeSqliteDatabase(databasePath, { readOnly: true }); + const builtInMemoryTableSets = [ + { + meta: MEMORY_INDEX_META_TABLE, + sources: MEMORY_INDEX_SOURCES_TABLE, + chunks: MEMORY_INDEX_CHUNKS_TABLE, + }, + { meta: "meta", sources: "files", chunks: "chunks" }, + ] as const; + const builtInMemoryTables = builtInMemoryTableSets.flatMap(({ meta, sources, chunks }) => [ + meta, + sources, + chunks, + ]); + const tableNames = new Set( + db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${builtInMemoryTables.map(() => "?").join(", ")})`, + ) + .all(...builtInMemoryTables) + .map((row) => row.name) + .filter((name): name is string => typeof name === "string"), + ); + for (const tables of builtInMemoryTableSets) { + if ( + tableNames.has(tables.meta) && + db + .prepare(`SELECT 1 AS ok FROM ${tables.meta} WHERE key = ? LIMIT 1`) + .get(MEMORY_INDEX_META_KEY) + ) { + return true; + } + for (const tableName of [tables.sources, tables.chunks]) { + if ( + tableNames.has(tableName) && + db.prepare(`SELECT 1 AS ok FROM ${tableName} LIMIT 1`).get() + ) { + return true; + } + } + } + return false; + } catch { + return false; + } finally { + try { + db?.close(); + } catch {} + } +} diff --git a/extensions/memory-core/status-api.ts b/extensions/memory-core/status-api.ts new file mode 100644 index 000000000000..8424251fa380 --- /dev/null +++ b/extensions/memory-core/status-api.ts @@ -0,0 +1 @@ +export { inspectMemoryIndexPresence } from "./src/memory/manager-status-presence.runtime.js"; diff --git a/scripts/check-kysely-guardrails.mts b/scripts/check-kysely-guardrails.mts index 04d9b4f0c07b..1b3e72e48276 100644 --- a/scripts/check-kysely-guardrails.mts +++ b/scripts/check-kysely-guardrails.mts @@ -105,7 +105,7 @@ const rawSqliteAllowPathGroups = { ], "read-only SQLite status probes": [ "src/commands/doctor-db-bloat.read.ts", - "src/commands/status.scan.shared.ts", + "extensions/memory-core/src/memory/manager-status-presence.ts", ], "doctor SQLite maintenance and legacy state migration": [ "src/commands/doctor-agent-memory-schema.ts", diff --git a/src/commands/status.memory-presence.worker.test.ts b/src/commands/status.memory-presence.worker.test.ts new file mode 100644 index 000000000000..b91f82941905 --- /dev/null +++ b/src/commands/status.memory-presence.worker.test.ts @@ -0,0 +1,240 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync, StatementSync } from "node:sqlite"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { buildMemoryIndexStrictSchema } from "../../packages/memory-host-sdk/src/host/memory-schema-base.js"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OutputRuntimeEnv } from "../runtime.js"; +import { + applyStatusScanDefaults, + createStatusMemorySearchManager, + createStatusScanSharedMocks, + loadStatusScanModuleForTest, +} from "./status.scan.test-helpers.js"; + +const tempDirs = useAutoCleanupTempDirTracker((cleanup) => + afterEach(async () => { + try { + const pools = [...(poolRun?.pools ?? [])]; + await Promise.all(pools.map((pool) => pool.rotate())); + for (const pool of pools) { + expect(pool.isClosed).toBe(false); + expect(pool.getSnapshot()).toMatchObject({ workers: 0, activeTasks: 0, pendingTasks: 0 }); + } + cleanup(); + } finally { + vi.unstubAllEnvs(); + } + }), +); +const mocks = { + ...createStatusScanSharedMocks("memory-presence-worker"), + callGateway: vi.fn(async () => null), + resolveMemorySearchConfig: vi.fn(), +}; +const writeJson = vi.fn<(value: unknown, space?: number) => void>(); +const runtime: OutputRuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + writeStdout: vi.fn(), + writeJson, +}; +let statusJsonCommand: typeof import("./status-json.js").statusJsonCommand; +let sqliteOwner: typeof import("../infra/node-sqlite.js"); +let poolRun: Awaited> | undefined; + +async function observeWorkerPools() { + const { resolveBundledPublicSurfaceLocation } = await import("../plugin-sdk/facade-loader.js"); + const { getCachedPluginModuleLoader } = await import("../plugins/plugin-module-loader-cache.js"); + const { preparePluginLoaderAliases } = await import("../plugins/sdk-alias.js"); + const location = resolveBundledPublicSurfaceLocation({ + dirName: "memory-core", + artifactBasename: "status-api.js", + preferSource: false, + }); + if (!location) { + throw new Error("Memory status artifact was not resolved"); + } + // The public artifact uses its native module graph, outside Vitest's module cache. + const facadeUrl = new URL("../plugin-sdk/facade-loader.ts", import.meta.url).href; + const loader = getCachedPluginModuleLoader({ + modulePath: location.modulePath, + importerUrl: facadeUrl, + loaderFilename: facadeUrl, + preferBuiltDist: true, + }); + const sdkPath = preparePluginLoaderAliases({ + modulePath: location.modulePath, + moduleUrl: facadeUrl, + argv1: process.argv[1], + }).resolveAlias("openclaw/plugin-sdk/process-runtime"); + if (!sdkPath) { + throw new Error("Memory worker SDK was not resolved"); + } + const { WorkerTaskPool } = loader(sdkPath) as typeof import("../plugin-sdk/process-runtime.js"); + type Pool = InstanceType; + const pools = new Set(); + let nextFailure: Error | undefined; + // oxlint-disable-next-line typescript/unbound-method -- Every intercepted call supplies the actual pool receiver via apply. + const originalRun = WorkerTaskPool.prototype.run; + const run = vi.spyOn(WorkerTaskPool.prototype, "run").mockImplementation(function ( + this: Pool, + ...args: Parameters + ) { + pools.add(this); + if (nextFailure) { + const failure = nextFailure; + nextFailure = undefined; + return Promise.reject(failure); + } + return originalRun.apply(this, args); + }); + return { + run, + pools, + rejectNextInspection(error: Error) { + nextFailure = error; + }, + }; +} + +beforeAll(async () => { + applyStatusScanDefaults(mocks); + await loadStatusScanModuleForTest(mocks, { fastJson: true }); + vi.doMock("../gateway/connection-details.js", async () => ({ + ...(await vi.importActual( + "../gateway/connection-details.js", + )), + buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, + buildGatewayConnectionDetailsWithResolvers: mocks.buildGatewayConnectionDetails, + })); + vi.doMock("./status-runtime-shared.ts", () => ({ + resolveStatusRuntimeSnapshot: async () => ({ + gatewayService: { label: "test-service", installed: false }, + nodeService: { label: "test-service", installed: false }, + }), + })); + vi.doMock("../state/backup-run-records.js", () => ({ + readBackupRunFreshness: async () => ({}), + })); + vi.doMock("../infra/update-run-status.js", () => ({ readUpdateRunStatus: () => ({}) })); + vi.resetModules(); + ({ statusJsonCommand } = await import("./status-json.js")); + sqliteOwner = await import("../infra/node-sqlite.js"); +}); + +beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockClear(); + } + writeJson.mockClear(); + const memoryManager = createStatusMemorySearchManager(); + memoryManager.manager.status.mockReturnValue({ files: 1, chunks: 0, dirty: false }); + applyStatusScanDefaults(mocks, { memoryManager }); +}); + +afterAll(async () => { + poolRun?.run.mockRestore(); + const { resetFacadeLoaderStateForTest } = await import("../plugin-sdk/facade-loader.js"); + const { waitForPluginCacheRetirement } = await import("../plugins/plugin-cache.js"); + resetFacadeLoaderStateForTest(); + await waitForPluginCacheRetirement(); +}); + +it.each(["populated", "empty", "unrelated", "missing", "unavailable"] as const)( + "keeps offline JSON-all %s memory presence off the caller's SQLite thread", + async (kind) => { + const stateDir = tempDirs.make("openclaw-status-memory-presence-"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const { resolveDefaultMemoryDatabasePath } = await import("./status.scan-memory.js"); + const databasePath = resolveDefaultMemoryDatabasePath("main"); + if (kind !== "missing") { + fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + const database = new DatabaseSync(databasePath); + try { + database.exec( + kind === "unrelated" + ? "CREATE TABLE unrelated_feature (id INTEGER PRIMARY KEY) STRICT" + : buildMemoryIndexStrictSchema({ + embeddingCacheTable: "memory_embedding_cache", + includeEmbeddingCache: false, + }), + ); + if (kind === "populated" || kind === "unavailable") { + database.exec( + "INSERT INTO memory_index_sources (path, source, hash, mtime, size) VALUES ('MEMORY.md', 'memory', 'synthetic', 1, 10)", + ); + } + } finally { + database.close(); + } + } + const before = kind === "missing" ? undefined : fs.readFileSync(databasePath); + mocks.resolveMemorySearchConfig.mockReturnValue({ store: { databasePath } }); + const observer = (poolRun ??= await observeWorkerPools()); + if (kind === "unavailable") { + // Exercise dispatch rejection without creating a failed native worker. + observer.rejectNextInspection(new Error("Synthetic inspection unavailable")); + } + const nativeCalls = [ + vi.spyOn(sqliteOwner, "openNodeSqliteDatabase"), + vi.spyOn(DatabaseSync.prototype, "prepare"), + vi.spyOn(DatabaseSync.prototype, "exec"), + vi.spyOn(DatabaseSync.prototype, "close"), + vi.spyOn(StatementSync.prototype, "all"), + vi.spyOn(StatementSync.prototype, "get"), + vi.spyOn(StatementSync.prototype, "run"), + ]; + try { + const samples = []; + for (let attempt = 0; attempt < (kind === "populated" ? 4 : 1); attempt++) { + writeJson.mockClear(); + mocks.getMemorySearchManager.mockClear(); + observer.run.mockClear(); + for (const call of nativeCalls) { + call.mockClear(); + } + const started = performance.now(); + await statusJsonCommand({ all: true }, runtime); + const durationMs = performance.now() - started; + if (kind === "missing") { + expect(observer.run).not.toHaveBeenCalled(); + } else { + expect(observer.run).toHaveBeenCalled(); + expect(observer.pools.size).toBeGreaterThan(0); + } + expect(writeJson).toHaveBeenCalledOnce(); + expect(writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ reachable: false }), + memory: + kind === "populated" ? { agentId: "main", files: 1, chunks: 0, dirty: false } : null, + }), + 2, + ); + expect(mocks.getMemorySearchManager).toHaveBeenCalledTimes(kind === "populated" ? 1 : 0); + const parentSqlCalls = nativeCalls.map((call) => call.mock.calls.length); + expect(parentSqlCalls).toEqual([0, 0, 0, 0, 0, 0, 0]); + samples.push({ attempt, durationMs, parentSqlCalls }); + } + console.info( + JSON.stringify({ + fixture: kind, + timingScope: "handler after same-loader SDK observer setup", + samples, + json: writeJson.mock.calls[0]?.[0], + }), + ); + if (before) { + expect(fs.readFileSync(databasePath)).toEqual(before); + } else { + expect(fs.existsSync(databasePath)).toBe(false); + } + } finally { + for (const call of nativeCalls) { + call.mockRestore(); + } + } + }, +); diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index f408892071fd..a711c304740e 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -2,7 +2,6 @@ // This file owns the cross-command contracts reused by normal, JSON, and status-all scans. import { existsSync } from "node:fs"; -import type { DatabaseSync } from "node:sqlite"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -14,7 +13,6 @@ import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { isLoopbackGatewayUrl } from "../gateway/net.js"; import { resolveGatewayProbeTarget } from "../gateway/probe-target.js"; import type { GatewayProbeResult, probeGateway as probeGatewayFn } from "../gateway/probe.js"; -import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -28,10 +26,13 @@ import { isProbeReachable } from "./gateway-status/helpers.js"; const gatewayProbeModuleLoader = createLazyImportLoader(() => import("./status.gateway-probe.js")); const probeGatewayModuleLoader = createLazyImportLoader(() => import("../gateway/probe.js")); const gatewayCallModuleLoader = createLazyImportLoader(() => import("../gateway/call.js")); -const memoryEngineStorageModuleLoader = createLazyImportLoader( - () => import("../memory-host-sdk/engine-storage.js"), -); -const MEMORY_INDEX_META_KEY = "memory_index_meta_v1"; +const memoryPresenceModuleLoader = createLazyImportLoader(async () => { + const { loadBundledPluginPublicArtifactModuleSync } = + await import("../plugins/public-surface-loader.js"); + return loadBundledPluginPublicArtifactModuleSync<{ + inspectMemoryIndexPresence: (databasePath: string) => Promise; + }>({ dirName: "memory-core", artifactBasename: "status-api.js" }); +}); export function resolveStatusGatewayProbeTimeoutMs(opts: { timeoutMs?: number; @@ -56,65 +57,8 @@ async function hasBuiltInMemoryState(databasePath: string): Promise { if (!existsSync(databasePath)) { return false; } - const { MEMORY_INDEX_CHUNKS_TABLE, MEMORY_INDEX_META_TABLE, MEMORY_INDEX_SOURCES_TABLE } = - await memoryEngineStorageModuleLoader.load(); - let db: DatabaseSync | undefined; - try { - db = openNodeSqliteDatabase(databasePath, { readOnly: true }); - const builtInMemoryTableSets = [ - { - meta: MEMORY_INDEX_META_TABLE, - sources: MEMORY_INDEX_SOURCES_TABLE, - chunks: MEMORY_INDEX_CHUNKS_TABLE, - }, - { - meta: "meta", - sources: "files", - chunks: "chunks", - }, - ] as const; - const builtInMemoryTables = builtInMemoryTableSets.flatMap(({ meta, sources, chunks }) => [ - meta, - sources, - chunks, - ]); - const tableNames = new Set( - ( - db - .prepare( - `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${builtInMemoryTables.map(() => "?").join(", ")})`, - ) - .all(...builtInMemoryTables) as Array<{ name?: unknown }> - ) - .map((row) => row.name) - .filter((name): name is string => typeof name === "string"), - ); - for (const tables of builtInMemoryTableSets) { - if ( - tableNames.has(tables.meta) && - db - .prepare(`SELECT 1 AS ok FROM ${tables.meta} WHERE key = ? LIMIT 1`) - .get(MEMORY_INDEX_META_KEY) - ) { - return true; - } - for (const tableName of [tables.sources, tables.chunks]) { - if ( - tableNames.has(tableName) && - db.prepare(`SELECT 1 AS ok FROM ${tableName} LIMIT 1`).get() - ) { - return true; - } - } - } - return false; - } catch { - return false; - } finally { - try { - db?.close(); - } catch {} - } + const { inspectMemoryIndexPresence } = await memoryPresenceModuleLoader.load(); + return await inspectMemoryIndexPresence(databasePath); } export type MemoryStatusSnapshot = MemoryProviderStatus & { diff --git a/src/memory-host-sdk/engine-storage.ts b/src/memory-host-sdk/engine-storage.ts index 9b6784cf3372..150c6323e17f 100644 --- a/src/memory-host-sdk/engine-storage.ts +++ b/src/memory-host-sdk/engine-storage.ts @@ -1,11 +1,2 @@ -/** - * Core-facing facade for memory backend storage config resolution. Keep this - * path stable while the shared SDK package owns provider status semantics. - */ -export { - MEMORY_INDEX_CHUNKS_TABLE, - MEMORY_INDEX_META_TABLE, - MEMORY_INDEX_SOURCES_TABLE, - resolveMemoryBackendConfig, - type MemoryProviderStatus, -} from "../../packages/memory-host-sdk/src/engine-storage.js"; +/** Core-facing memory status types; runtime storage belongs to the memory plugin. */ +export type { MemoryProviderStatus } from "../../packages/memory-host-sdk/src/engine-storage.js";