diff --git a/src/commands/doctor-session-sqlite-readers.ts b/src/commands/doctor-session-sqlite-readers.ts index 6a16021b5985..4ca72fc6afab 100644 --- a/src/commands/doctor-session-sqlite-readers.ts +++ b/src/commands/doctor-session-sqlite-readers.ts @@ -14,11 +14,13 @@ import type { FileEntry } from "../agents/sessions/session-manager-types.js"; import type { TranscriptEvent } from "../config/sessions/session-accessor.js"; import type { SqliteTranscriptStorageRow } from "../config/sessions/session-accessor.sqlite-read.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; -import type { SessionStoreTarget } from "../config/sessions/targets.js"; +import type { SessionStoreTarget as ResolvedSessionStoreTarget } from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js"; +type SessionStoreTarget = ResolvedSessionStoreTarget & { sqlitePath?: string }; + type ReadOnlySqliteSessionSummary = { entry: SessionEntry; sessionKey: string; @@ -472,6 +474,9 @@ export function readOnlySqliteDbStats(target: SessionStoreTarget): ReadOnlySqlit } export function resolveTargetSqlitePath(target: SessionStoreTarget): string { + if (target.sqlitePath) { + return resolveOpenClawAgentSqlitePath({ agentId: target.agentId, path: target.sqlitePath }); + } const sqliteTarget = resolveSqliteTargetFromSessionStorePath(target.storePath, { agentId: target.agentId, }); diff --git a/src/commands/doctor-session-sqlite-types.ts b/src/commands/doctor-session-sqlite-types.ts index 53689c1f805d..b22b12432f31 100644 --- a/src/commands/doctor-session-sqlite-types.ts +++ b/src/commands/doctor-session-sqlite-types.ts @@ -177,6 +177,13 @@ export function createDoctorSessionSqliteTotals( > = {}, ): DoctorSessionSqliteReport["totals"] { const { archivedLegacyStoreFiles, reclaimedBytes } = values; + const sqliteEntries = new Map(); + for (const target of targets) { + sqliteEntries.set( + target.sqlitePath, + Math.max(sqliteEntries.get(target.sqlitePath) ?? 0, target.sqliteEntries), + ); + } return { ...(archivedLegacyStoreFiles === undefined ? {} : { archivedLegacyStoreFiles }), archivedTranscriptFiles: values.archivedTranscriptFiles ?? 0, @@ -186,7 +193,7 @@ export function createDoctorSessionSqliteTotals( issues: sumDoctorSessionSqliteTargets(targets, (target) => target.issues.length), legacyEntries: values.legacyEntries ?? 0, ...(reclaimedBytes === undefined ? {} : { reclaimedBytes }), - sqliteEntries: sumDoctorSessionSqliteTargets(targets, (target) => target.sqliteEntries), + sqliteEntries: [...sqliteEntries.values()].reduce((total, count) => total + count, 0), targets: targets.length, unreferencedJsonlFiles: values.unreferencedJsonlFiles ?? 0, validatedEntries: values.validatedEntries ?? 0, diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index 28e4b7c0849f..cbd1eb97fbeb 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -2638,6 +2638,96 @@ describe("runDoctorSessionSqlite", () => { } }); + it("partitions the retired top-level store without guessing unscoped ownership", async () => { + const stateDir = autoCleanupTempDirs.make("openclaw-doctor-retired-sessions-"); + const sessionDir = path.join(stateDir, "sessions"); + const storePath = path.join(sessionDir, "sessions.json"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:main:main": { + sessionFile: "/retired/home/.openclaw/sessions/main-session.jsonl", + sessionId: "main-会議", + updatedAt: 20, + }, + "agent:ops:main": { + sessionFile: "ops-session.jsonl", + sessionId: "ops-session", + updatedAt: 30, + }, + "voice:ambiguous": { sessionId: "ambiguous-session", updatedAt: 40 }, + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(sessionDir, "main-session.jsonl"), + '{"type":"session","sessionId":"main-会議"}\n', + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(sessionDir, "ops-session.jsonl"), + '{"type":"session","sessionId":"ops-session"}\n', + { mode: 0o600 }, + ); + + const cfg = { + agents: { ownership: "explicit" as const, entries: { main: {}, ops: {} } }, + }; + const report = await runDoctorSessionSqlite({ + allAgents: true, + cfg, + env, + mode: "import", + }); + + expect(report.targets.map((target) => target.agentId)).toEqual(["main", "ops"]); + expect(report.totals).toMatchObject({ + archivedLegacyStoreFiles: 0, + importedEntries: 2, + importedTranscriptEvents: 2, + legacyEntries: 2, + sqliteEntries: 2, + }); + for (const [agentId, sessionId] of [ + ["main", "main-会議"], + ["ops", "ops-session"], + ] as const) { + const agentStorePath = path.join(stateDir, "agents", agentId, "sessions", "sessions.json"); + expect( + loadExactSessionEntry({ + agentId, + sessionKey: `agent:${agentId}:main`, + storePath: agentStorePath, + })?.entry.sessionId, + ).toBe(sessionId); + expect( + loadExactSessionEntry({ + agentId, + sessionKey: "voice:ambiguous", + storePath: agentStorePath, + }), + ).toBeUndefined(); + } + expect(fs.existsSync(storePath)).toBe(true); + expect(fs.existsSync(path.join(sessionDir, "main-session.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(sessionDir, "ops-session.jsonl"))).toBe(true); + + const owned = await runDoctorSessionSqlite({ + allAgents: true, + cfg: { + ...cfg, + agents: { ...cfg.agents, defaults: { sessionStore: { agentId: "main" } } }, + }, + env, + mode: "import", + }); + expect(owned.totals.archivedLegacyStoreFiles).toBe(1); + expect(owned.totals.importedEntries).toBe(3); + expect(fs.existsSync(storePath)).toBe(false); + }); + it("imports shared custom stores into per-agent SQLite targets", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-session-sqlite-")); try { diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index c534e4e68b7f..4028ad022450 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; +import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { resolveStateDir } from "../config/paths.js"; import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePathCore } from "../config/sessions/paths.js"; @@ -14,7 +15,7 @@ import { resolveAllAgentSessionStoreCandidateTargetsSync, resolveAllAgentSessionStoreTargetsSync, resolveSessionStoreTargets, - type SessionStoreTarget, + type SessionStoreTarget as ResolvedSessionStoreTarget, } from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -23,7 +24,11 @@ import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; import { isPathInside } from "../infra/path-guards.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; import { normalizeLegacySessionEntryDelivery as normalizeSessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; -import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { + LEGACY_IMPLICIT_AGENT_ID, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; import { closeOpenClawAgentDatabaseByPath } from "../state/openclaw-agent-db.js"; import { compactDoctorSessionSqliteTarget } from "./doctor-session-sqlite-compact.js"; import { @@ -85,6 +90,8 @@ export type { DoctorSessionSqliteTargetReport, } from "./doctor-session-sqlite-types.js"; +type SessionStoreTarget = ResolvedSessionStoreTarget & { sqlitePath?: string }; + type LegacySessionRecord = { entry: SessionEntry; sessionKey: string; @@ -256,10 +263,27 @@ function resolveDoctorSessionSqliteTargets(params: { ); } if (params.allAgents) { - return filterLegacySessionStoreTargets( + const targets = filterLegacySessionStoreTargets( resolveAllAgentSessionStoreTargetsSync(params.cfg, { env: params.env }), params.mode, ); + if (params.mode !== "dry-run" && params.mode !== "import" && params.mode !== "validate") { + return targets; + } + const legacyStorePath = path.join(resolveStateDir(params.env), "sessions", "sessions.json"); + if (!fs.existsSync(legacyStorePath)) { + return targets; + } + const legacyTargets = resolveSessionStoreTargets( + params.cfg, + { allAgents: true }, + { env: params.env }, + ).map((target) => ({ + agentId: target.agentId, + sqlitePath: resolveTargetSqlitePath(target), + storePath: legacyStorePath, + })); + return [...legacyTargets, ...targets]; } return resolveSessionStoreTargets(params.cfg, {}, { env: params.env }).filter((target) => fs.existsSync(target.storePath), @@ -505,6 +529,16 @@ function isLegacySessionRecordOwnedByTarget( target: SessionStoreTarget, sessionKey: string, ): boolean { + if (target.sqlitePath) { + const parsed = parseAgentSessionKey(sessionKey); + const ownerAgentId = + parsed?.agentId ?? + cfg.agents?.defaults?.sessionStore?.agentId?.trim() ?? + tryResolveLegacyCompatibilityAgentId(cfg); + return ownerAgentId + ? normalizeAgentId(ownerAgentId) === normalizeAgentId(target.agentId) + : false; + } const ownerAgentId = resolveStoredSessionOwnerAgentId({ cfg, agentId: target.agentId, @@ -529,14 +563,29 @@ function resolveLegacyTranscriptPath( if (parseSqliteSessionFileMarker(legacySessionFile)) { return undefined; } - const defaultPath = resolveSessionFilePathCore(entry.sessionId, entry, { - agentId: target.agentId, - sessionsDir: path.dirname(target.storePath), - }); + const sessionsDir = path.dirname(target.storePath); + const relocatedPath = legacySessionFile?.trim() + ? path.join(sessionsDir, path.basename(legacySessionFile)) + : undefined; + let defaultPath: string; + try { + defaultPath = resolveSessionFilePathCore(entry.sessionId, entry, { + agentId: target.agentId, + sessionsDir, + }); + } catch (error) { + if (!relocatedPath) { + throw error; + } + defaultPath = relocatedPath; + } if (fs.existsSync(defaultPath)) { return defaultPath; } - return legacySessionFile?.trim() ? defaultPath : undefined; + if (relocatedPath && fs.existsSync(relocatedPath)) { + return relocatedPath; + } + return relocatedPath ? defaultPath : undefined; } function countLegacyTranscript( @@ -591,7 +640,7 @@ async function importLegacySessionRecord( entry: record.entry, preserveExactStoredKey: true, sessionKey: record.sessionKey, - storePath: target.storePath, + storePath: target.sqlitePath ?? target.storePath, }); report.importedEntries += 1; report.importedTranscriptEvents += imported.transcriptEvents; @@ -608,7 +657,7 @@ async function importLegacySessionRecord( entry: record.entry, preserveExactStoredKey: true, sessionKey: record.sessionKey, - storePath: target.storePath, + storePath: target.sqlitePath ?? target.storePath, ...(record.transcriptPath && shouldImportTranscript ? { readTranscriptEvents: createTranscriptEventPrefixReader( @@ -637,7 +686,7 @@ async function importLegacySessionRecord( entry: record.entry, preserveExactStoredKey: true, sessionKey: record.sessionKey, - storePath: target.storePath, + storePath: target.sqlitePath ?? target.storePath, ...(record.transcriptPath && result.status === "ok" && shouldImportTranscript ? { readTranscriptEvents: createTranscriptEventReader(