mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
fix(doctor): partition retired session stores (#124846)
Recover owner-qualified rows from the retired top-level session store into each configured agent database without guessing ambiguous ownership. Relocate stale transcript paths, tolerate historical Unicode IDs when an explicit transcript exists, and avoid double-counting physical SQLite databases in migration totals. Amp-Thread-ID: https://ampcode.com/threads/T-01a00a6a-b64e-74a5-8b15-2d3b966a468d Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
committed by
GitHub
parent
22ab8e3c2c
commit
fecb377da6
@@ -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,
|
||||
});
|
||||
|
||||
@@ -177,6 +177,13 @@ export function createDoctorSessionSqliteTotals(
|
||||
> = {},
|
||||
): DoctorSessionSqliteReport["totals"] {
|
||||
const { archivedLegacyStoreFiles, reclaimedBytes } = values;
|
||||
const sqliteEntries = new Map<string, number>();
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user