fix: repair legacy message and route shapes at the sqlite import boundary

This commit is contained in:
Josh Lehman 2026-07-07 14:20:07 -07:00
parent 6bea48c671
commit 2fdeeeb515
No known key found for this signature in database
GPG key ID: D141B425AC7F876B
5 changed files with 67 additions and 6 deletions

View file

@ -1015,7 +1015,13 @@ function parseJsonlEntries(content: string): FileEntry[] {
return entries;
}
function normalizeLoadedFileEntry(entry: FileEntry): FileEntry {
/**
* Repairs legacy JSONL message shapes (string assistant/toolResult content) into
* canonical block arrays. Every legacy-JSONL ingress point file-era transcript
* reads and the doctor SQLite import must apply this so the SQLite store only
* ever holds canonical rows; the SQLite read path does no repair.
*/
export function normalizeLoadedFileEntry(entry: FileEntry): FileEntry {
if (!isJsonRecord(entry) || entry.type !== "message" || !isJsonRecord(entry.message)) {
return entry;
}

View file

@ -2,6 +2,7 @@
import fs from "node:fs";
import { TextDecoder } from "node:util";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLoadedFileEntry, type FileEntry } from "../agents/sessions/session-manager.js";
import type { TranscriptEvent } from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { SessionStoreTarget } from "../config/sessions/targets.js";
@ -77,7 +78,9 @@ export function createTranscriptEventReader(
for (const line of iterateJsonlLinesSync(transcriptPath)) {
const parsed = parseJsonlLine(line);
if (parsed) {
append(parsed as TranscriptEvent);
// Import is the migration boundary: repair legacy JSONL message shapes
// here because the SQLite runtime read path assumes canonical rows.
append(normalizeLoadedFileEntry(parsed as FileEntry) as TranscriptEvent);
}
}
};
@ -91,7 +94,7 @@ export function createTranscriptEventPrefixReader(
for (const line of iterateJsonlLinesSync(transcriptPath)) {
const parsed = parseJsonlLine(line);
if (parsed) {
append(parsed as TranscriptEvent);
append(normalizeLoadedFileEntry(parsed as FileEntry) as TranscriptEvent);
}
}
} catch {

View file

@ -117,6 +117,44 @@ describe("runDoctorSessionSqlite", () => {
}
});
it("repairs legacy message and route shapes at the import boundary", async () => {
const store = createLegacyStore({
entryOverrides: {
route: "stale-custom-slot",
deliveryContext: { channel: "telegram", to: "123" },
},
transcriptLines: [
'{"type":"session","sessionId":"session-1"}',
'{"type":"message","id":"m1","parentId":null,"message":{"role":"assistant","content":"legacy string"}}',
],
});
const report = await runDoctorSessionSqlite({
env: store.env,
mode: "import",
store: store.storePath,
});
expect(report.totals).toMatchObject({ importedEntries: 1, issues: 0 });
const imported = loadExactSqliteSessionEntry({
agentId: "main",
sessionKey: "agent:main:main",
storePath: store.storePath,
});
// The SQLite runtime does no read repair, so import must store canonical shapes.
expect(typeof imported?.entry.route).not.toBe("string");
const events = loadSqliteTranscriptEventsSync({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath: store.storePath,
});
const message = events.find((event) => (event as { type?: string }).type === "message") as {
message?: { content?: unknown };
};
expect(message?.message?.content).toEqual([{ type: "text", text: "legacy string" }]);
});
it("imports and validates legacy sessions idempotently", async () => {
const store = createLegacyStore();
@ -1118,7 +1156,12 @@ describe("runDoctorSessionSqlite", () => {
});
function createLegacyStore(
params: { agentDirName?: string; customStore?: boolean; transcriptLines?: string[] } = {},
params: {
agentDirName?: string;
customStore?: boolean;
entryOverrides?: Record<string, unknown>;
transcriptLines?: string[];
} = {},
): TestStore {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-session-sqlite-"));
const stateDir = path.join(tempDir, "state");
@ -1143,6 +1186,7 @@ function createLegacyStore(
sessionId: "session-1",
sessionStartedAt: 1000,
updatedAt: 2000,
...params.entryOverrides,
},
},
null,

View file

@ -11,6 +11,7 @@ import {
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { normalizeStoreSessionKey } from "../config/sessions/store-entry.js";
import { normalizeSessionEntryDelivery } from "../config/sessions/store-load.js";
import {
resolveAgentSessionStoreTargetsSync,
resolveAllAgentSessionStoreTargetsSync,
@ -323,7 +324,9 @@ function readLegacySessionRecords(
continue;
}
records.push({
entry: value,
// Import is the migration boundary: repair legacy delivery/route shapes
// here because the SQLite runtime read path assumes canonical entries.
entry: normalizeSessionEntryDelivery(value),
sessionKey,
transcriptPath: resolveLegacyTranscriptPath(target, value),
});

View file

@ -296,7 +296,12 @@ function sameDeliveryChannelRoute(
);
}
function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
/**
* Rebuilds malformed/legacy delivery `route` state from the entry's delivery
* fields. Runs on file-era store loads and on the doctor SQLite import so the
* SQLite store only holds canonical delivery shapes; SQLite reads do no repair.
*/
export function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
const entryRoute = normalizeDeliveryChannelRoute(entry.route);
const normalized = normalizeSessionDeliveryFields({
route: entryRoute,