diff --git a/docs/cli/doctor/state-migrations.md b/docs/cli/doctor/state-migrations.md index 4b901fdeb531..ab505b2ccf44 100644 --- a/docs/cli/doctor/state-migrations.md +++ b/docs/cli/doctor/state-migrations.md @@ -27,6 +27,16 @@ The pre-July plugin install index at `plugins/installs.json` is no longer import or archived. Doctor preserves it and stops with the same intermediate-upgrade guidance. July-era SQLite plugin install records remain supported. +Session records that need the retired `room` → `groupChannel` conversion are refused +without changing their original bytes. Preserve the state, install OpenClaw +`2026.9.5`, run `openclaw doctor --fix`, then upgrade again. A canonical +`groupChannel` with an ignored `room` field remains unchanged. + +The July Doctor importer could still write `provider` and `lastProvider` aliases. +Doctor retains their repair, backs up existing SQLite rows, and updates canonical +delivery metadata and its query projections together. Runtime reads require that +repair; canonical delivery fields and unrelated stored values keep their values. + ## Legacy state migration When Doctor selects a legacy home such as `~/.clawdbot`, it drains open database diff --git a/src/commands/doctor-session-delivery-state.ts b/src/commands/doctor-session-delivery-state.ts index 502e0f6c80d7..e4d62eccd97f 100644 --- a/src/commands/doctor-session-delivery-state.ts +++ b/src/commands/doctor-session-delivery-state.ts @@ -1,6 +1,9 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { scanDoctorSessionEntriesTolerant } from "../config/sessions/session-accessor.js"; -import { hasLegacySessionEntryState } from "../config/sessions/session-entry-state-format.js"; +import { + hasLegacySessionEntryState, + hasLegacySessionProviderState, +} from "../config/sessions/session-entry-state-format.js"; import { stripRuntimeOnlySessionSkillsFields } from "../config/sessions/store-entry-shape.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -206,11 +209,16 @@ export async function repairLegacySessionEntryStates(params: { ...params, source: "raw", rawNeedsRepair: hasLegacySessionEntryState, - rawTransform: (entry, _sessionKey, updatedAt) => - hasLegacySessionEntryState(entry) - ? migrateLegacySessionEntryState(entry, updatedAt) - : entry, - updateDeliveryProjection: false, + rawTransform: (entry, _sessionKey, updatedAt) => { + if (!hasLegacySessionEntryState(entry)) { + return entry; + } + const next = migrateLegacySessionEntryState(entry, updatedAt); + return hasLegacySessionProviderState(entry) + ? normalizeLegacySessionEntryDelivery(next) + : next; + }, + updateDeliveryProjection: true, }); const report = { found: plan.found, repaired: 0, scannedStores: plan.scannedStores }; if (!params.apply || plan.found === 0) { diff --git a/src/commands/doctor-session-entry-state.test.ts b/src/commands/doctor-session-entry-state.test.ts index 681f082d3f66..165d8e9e740b 100644 --- a/src/commands/doctor-session-entry-state.test.ts +++ b/src/commands/doctor-session-entry-state.test.ts @@ -101,6 +101,15 @@ it("backs up original rows and migrates pending delivery state before canonical const transport = seedEntry("transport", { pendingFinalDelivery: true }); const cleared = seedEntry("cleared", { pendingFinalDelivery: false }); const nullLegacyField = seedEntry("null-legacy-field", { memoryFlushAt: null }); + const routing = seedEntry("routing", { + delivery: undefined, + provider: "slack", + lastProvider: "telegram", + lastTo: "synthetic-recipient", + lastAccountId: "work", + room: "opaque alias", + groupChannel: "#canonical", + }); const canonicalPending = { kind: "replayable", text: "current reply", @@ -118,7 +127,11 @@ it("backs up original rows and migrates pending delivery state before canonical memoryFlushCompactionCount: 1, memoryFlushFailureCount: 2, }); - const unchanged = seedEntry("unchanged", { pendingFinalDelivery: canonicalPending }); + const unchanged = seedEntry("unchanged", { + pendingFinalDelivery: canonicalPending, + provider: "opaque provider", + lastProvider: "opaque last provider", + }); const db = openOpenClawAgentDatabase({ agentId: "main", env: state.env }); const snapshot = { prompt: "retained snapshot", skills: [] }; db.db @@ -128,8 +141,9 @@ it("backs up original rows and migrates pending delivery state before canonical .run(legacy.sessionKey, JSON.stringify(snapshot)); expect(() => loadExactSessionEntryReadOnly(legacy.scope)).toThrow(/run openclaw doctor --fix/); + expect(() => loadExactSessionEntryReadOnly(routing.scope)).toThrow(/run openclaw doctor --fix/); expect(await repairLegacySessionEntryStates({ apply: false, cfg: {}, env: state.env })).toEqual({ - found: 5, + found: 6, repaired: 0, scannedStores: 1, }); @@ -140,7 +154,19 @@ it("backs up original rows and migrates pending delivery state before canonical run: (authority) => repairLegacySessionEntryStates({ apply: true, cfg: {}, env: state!.env, authority }), }); - expect(report).toMatchObject({ found: 5, repaired: 5 }); + expect(report).toMatchObject({ found: 6, repaired: 6 }); + expect(JSON.parse(String(routing.readRaw()))).toMatchObject({ + delivery: { kind: "external", context: { channel: "telegram", accountId: "work" } }, + room: "opaque alias", + groupChannel: "#canonical", + }); + expect(JSON.parse(String(routing.readRaw()))).not.toHaveProperty("provider"); + expect(JSON.parse(String(routing.readRaw()))).not.toHaveProperty("lastProvider"); + expect( + openOpenClawAgentDatabase({ agentId: "main", env: state.env }) + .db.prepare("SELECT channel, account_id FROM session_windows WHERE session_key = ?") + .get(routing.sessionKey), + ).toMatchObject({ channel: "telegram", account_id: "work" }); expect(JSON.parse(String(cleared.readRaw()))).not.toHaveProperty("pendingFinalDelivery"); expect(JSON.parse(String(nullLegacyField.readRaw()))).not.toHaveProperty("memoryFlushAt"); expect(unchanged.readRaw()).toBe(unchanged.raw); @@ -200,6 +226,11 @@ it("backs up original rows and migrates pending delivery state before canonical .prepare("SELECT entry_json FROM session_nodes WHERE session_key = ?") .get(legacy.sessionKey)?.entry_json, ).toBe(legacy.raw); + expect( + backup + .prepare("SELECT entry_json FROM session_nodes WHERE session_key = ?") + .get(routing.sessionKey)?.entry_json, + ).toBe(routing.raw); expect( backup .prepare("SELECT value_json FROM session_entry_snapshots WHERE session_key = ?") @@ -212,7 +243,35 @@ it("backs up original rows and migrates pending delivery state before canonical }); }); -it.each(["snapshot failure", "retired owner"] as const)( +it.each([false, true])( + "refuses retired room grouping before any row repair (apply: %s)", + async (apply) => { + state = await createOpenClawTestState({ + prefix: "openclaw-retired-room-", + scenario: "minimal", + }); + const supported = seedEntry("a-supported", { memoryFlushCompactionCount: 0 }); + const retired = seedEntry("retired", { room: "#legacy" }); + const canonical = seedEntry("canonical", { room: "opaque", groupChannel: "#current" }); + const raw = `${retired.raw.slice(0, -1)}, "groupChannel":"ignored", "groupChannel":null }`; + const database = openOpenClawAgentDatabase({ agentId: "main", env: state.env }); + database.db + .prepare("UPDATE session_nodes SET entry_json = ? WHERE session_key = ?") + .run(raw, retired.sessionKey); + expect(() => loadExactSessionEntryReadOnly(retired.scope)).toThrow(/2026\.9\.5/); + expect(loadExactSessionEntryReadOnly(canonical.scope)?.entry).toMatchObject({ + groupChannel: "#current", + }); + await expect( + repairLegacySessionEntryStates({ apply, cfg: {}, env: state.env }), + ).rejects.toThrow(/2026\.9\.5/); + expect(retired.readRaw()).toBe(raw); + expect(supported.readRaw()).toBe(supported.raw); + expect(canonical.readRaw()).toBe(canonical.raw); + }, +); + +it.each(["snapshot failure", "retired owner", "retired room introduced after scan"] as const)( "does not rewrite rows after %s", async (failure) => { state = await createOpenClawTestState({ @@ -225,12 +284,21 @@ it.each(["snapshot failure", "retired owner"] as const)( }); const createSnapshot = snapshots.createVerifiedSqliteSnapshot; let retired = false; + let expectedRaw = legacy.raw; vi.spyOn(snapshots, "createVerifiedSqliteSnapshot").mockImplementation(async (options) => { if (failure === "snapshot failure") { throw new Error("snapshot unavailable"); } const result = await createSnapshot(options); - retired = true; + if (failure === "retired owner") { + retired = true; + } else { + expectedRaw = JSON.stringify({ ...JSON.parse(legacy.raw), room: "#legacy" }); + using database = new DatabaseSync(legacy.databasePath); + database + .prepare("UPDATE session_nodes SET entry_json = ? WHERE session_key = ?") + .run(expectedRaw, legacy.sessionKey); + } return result; }); const refused = withDoctorSqliteMaintenanceLock({ @@ -253,10 +321,14 @@ it.each(["snapshot failure", "retired owner"] as const)( }); await expect(refused).rejects.toBeInstanceOf(DoctorStateMigrationRefusalError); await expect(refused).rejects.toThrow( - failure === "snapshot failure" ? "snapshot unavailable" : "repair owner retired", + failure === "snapshot failure" + ? "snapshot unavailable" + : failure === "retired owner" + ? "repair owner retired" + : "2026.9.5", ); - expect(legacy.readRaw()).toBe(legacy.raw); - expect(() => loadExactSessionEntryReadOnly(legacy.scope)).toThrow(/run openclaw doctor --fix/); + expect(legacy.readRaw()).toBe(expectedRaw); + expect(() => loadExactSessionEntryReadOnly(legacy.scope)).toThrow(/openclaw doctor --fix/); }, ); diff --git a/src/commands/doctor-session-sqlite-verification.ts b/src/commands/doctor-session-sqlite-verification.ts index d454440822bd..d598a231ca2c 100644 --- a/src/commands/doctor-session-sqlite-verification.ts +++ b/src/commands/doctor-session-sqlite-verification.ts @@ -9,6 +9,7 @@ import { } from "../config/sessions/session-entry-snapshots.js"; import { normalizeStoreSessionKey } from "../config/sessions/store-entry.js"; import type { SessionStoreTarget } from "../config/sessions/targets.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; import { executeSqliteQueryTakeFirstSync } from "../infra/kysely-sync.js"; import { @@ -165,7 +166,7 @@ export function verifyHistoricalMigrationArtifact(params: { return false; } attachSessionEntrySnapshots(current, row); - const entry = { ...raw, sessionId, updatedAt: raw.updatedAt }; + const entry: SessionEntry = { ...raw, sessionId, updatedAt: raw.updatedAt }; const normalized = migrateLegacySessionCreator(normalizeLegacySessionEntryDelivery(entry)); if ( Object.entries(normalized).some( diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index 25d0d20e223d..3aaec5f2eff0 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -42,19 +42,15 @@ function sessionScope(store: TestStore) { } describe("runDoctorSessionSqlite", () => { - it.each([ - ["provider", "channel"], - ["lastProvider", "lastChannel"], - ["room", "groupChannel"], - ])("refuses pre-July session field %s without changing the source", async (field, canonical) => { + it("refuses retired room grouping without changing the source", async () => { const store = createLegacyStore({ - entryOverrides: { [field]: "legacy", [canonical]: undefined }, + entryOverrides: { room: "legacy", groupChannel: undefined }, }); const originalStore = fs.readFileSync(store.storePath, "utf8"); const originalTranscript = fs.readFileSync(store.transcriptPath, "utf8"); await expect(importLegacyStore(store)).rejects.toThrow( - `Session field "${field}" predates July 2026 and is no longer supported`, + 'Session field "room" predates July 2026 and is no longer supported', ); expect(fs.readFileSync(store.storePath, "utf8")).toBe(originalStore); @@ -62,6 +58,47 @@ describe("runDoctorSessionSqlite", () => { expect(loadExactSessionEntry(sessionScope(store))).toBeUndefined(); }); + it.each(["provider", "lastProvider"])( + "imports supported July %s routing and archives original source bytes", + async (field) => { + const store = createLegacyStore({ + entryOverrides: { + channel: undefined, + lastChannel: undefined, + [field]: "telegram", + lastTo: "123", + lastAccountId: "work", + }, + }); + const originalStore = fs.readFileSync(store.storePath, "utf8"); + const originalTranscript = fs.readFileSync(store.transcriptPath, "utf8"); + + const report = await importLegacyStore(store); + + expect(report.totals).toMatchObject({ importedEntries: 1, issues: 0 }); + const imported = loadExactSessionEntry(sessionScope(store))?.entry; + expect(imported).toMatchObject({ + delivery: { + kind: "external", + context: { channel: "telegram", to: "123", accountId: "work" }, + }, + }); + expect(imported).not.toHaveProperty(field); + const manifest = readMigrationManifest(report.migrationRun?.manifestPath); + const target = expectDefined(manifest.targets[0], "imported target"); + for (const [kind, original] of [ + ["legacy-store", originalStore], + ["transcript", originalTranscript], + ] as const) { + const archived = expectDefined( + target.completedMoves.find((move) => move.kind === kind), + `archived ${kind}`, + ); + expect(fs.readFileSync(archived.archivePath, "utf8")).toBe(original); + } + }, + ); + it("repairs legacy transcript and route shapes at the import boundary", async () => { const store = createLegacyStore({ entryOverrides: { diff --git a/src/commands/doctor/shared/session-entry-rewrite.ts b/src/commands/doctor/shared/session-entry-rewrite.ts index 19c24144eaa8..2baeb32b9e85 100644 --- a/src/commands/doctor/shared/session-entry-rewrite.ts +++ b/src/commands/doctor/shared/session-entry-rewrite.ts @@ -18,6 +18,7 @@ import { } from "../../../config/sessions/session-entry-snapshots.js"; import { LEGACY_SESSION_ENTRY_STATE_FIELDS } from "../../../config/sessions/session-entry-state-format.js"; import { stripRuntimeOnlySessionSkillsFields } from "../../../config/sessions/store-entry-shape.js"; +import { assertSupportedSessionStoreEntry } from "../../../config/sessions/supported-session-store.js"; import type { SessionEntry } from "../../../config/sessions/types.js"; import { executeSqliteQuerySync, iterateSqliteQuerySync } from "../../../infra/kysely-sync.js"; import type { DatabaseFileIdentity } from "../../../infra/sqlite-worker-identity.js"; @@ -60,16 +61,18 @@ export function scanDoctorSessionEntryRecords( .selectFrom("session_nodes") .select(["session_key", "entry_json"]) .where( - /* kysely-allow-raw: JSON table-valued filtering keeps canonical payloads out of JavaScript. */ + /* kysely-allow-raw: coarse key filtering bounds parsing; JSON.parse owns duplicate-key precedence. */ sql`CASE WHEN json_valid(entry_json) THEN EXISTS ( SELECT 1 FROM json_each(entry_json) WHERE key IN (${sql.join(LEGACY_SESSION_ENTRY_STATE_FIELDS)}) + OR key IN ('provider', 'lastProvider', 'room') OR (key = 'pendingFinalDelivery' AND type IN ('true', 'false')) ) ELSE 1 END`, ), )) { const entry = parseDoctorSessionEntryRecord(row.entry_json); if (entry) { + assertSupportedSessionStoreEntry(entry); visit({ sessionKey: row.session_key, entry }); } } @@ -132,6 +135,7 @@ export function rewriteDoctorSessionEntries( } let entryJson: string; let nextEntry: SessionEntry | undefined; + let deliveryProjectionEntry: SessionEntry | undefined; let snapshots: ReturnType["snapshots"] | undefined; let entryValid = row.entry_valid; if (params.rawTransform) { @@ -139,6 +143,7 @@ export function rewriteDoctorSessionEntries( if (!entry) { continue; } + assertSupportedSessionStoreEntry(entry); const previousSessionId = entry.sessionId; const previousUpdatedAt = entry.updatedAt; const previousFields = new Map( @@ -172,14 +177,21 @@ export function rewriteDoctorSessionEntries( continue; } // Invalid identities remain for canonical-key repair; scalar migration cannot certify them. - if (!parseSqliteSessionEntryRecord({ ...row, entry_json: entryJson })) { + const parsedEntry = parseSqliteSessionEntryRecord({ ...row, entry_json: entryJson }); + if (!parsedEntry) { entryValid = 0; + } else if ( + params.updateDeliveryProjection && + previousFields.get("delivery") !== JSON.stringify(transformed.delivery) + ) { + deliveryProjectionEntry = parsedEntry; } } else { const entry = parseSqliteSessionEntryRecord(row); if (!entry) { continue; } + assertSupportedSessionStoreEntry(entry); attachSessionEntrySnapshots(entry, row); const previousJson = JSON.stringify(entry); const transformedEntry = params.transform(entry, sessionKey); @@ -215,14 +227,15 @@ export function rewriteDoctorSessionEntries( .set({ entry_valid: entryValid }) .where("session_key", "=", sessionKey), ); - if (nextEntry && params.updateDeliveryProjection) { + const projected = deliveryProjectionEntry ?? nextEntry; + if (projected && params.updateDeliveryProjection) { executeSqliteQuerySync( database.db, db .updateTable("session_windows") .set({ - account_id: deliveryContextFromSession(nextEntry)?.accountId ?? null, - channel: sessionDeliveryChannel(nextEntry) ?? null, + account_id: deliveryContextFromSession(projected)?.accountId ?? null, + channel: sessionDeliveryChannel(projected) ?? null, }) .where("session_id", "=", row.current_session_id), ); diff --git a/src/commands/doctor/shared/session-entry-shape.ts b/src/commands/doctor/shared/session-entry-shape.ts index 4f8cb0ec4185..ec4de8c6556c 100644 --- a/src/commands/doctor/shared/session-entry-shape.ts +++ b/src/commands/doctor/shared/session-entry-shape.ts @@ -2,7 +2,11 @@ import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-c import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { validateSessionId } from "../../../config/sessions/paths.js"; -import { LEGACY_SESSION_ENTRY_STATE_FIELDS } from "../../../config/sessions/session-entry-state-format.js"; +import { + hasLegacySessionProviderState, + LEGACY_SESSION_ENTRY_STATE_FIELDS, + LEGACY_SESSION_PROVIDER_FIELDS, +} from "../../../config/sessions/session-entry-state-format.js"; import { normalizePendingFinalDelivery, normalizeFallbackNotice, @@ -21,12 +25,20 @@ function normalizeCount(value: unknown): number | undefined { return number === undefined ? undefined : Math.floor(number); } -/** Doctor preserves the July scalar-state contract before removing its old keys. */ +/** Doctor preserves July routing and scalar-state contracts before removing their old keys. */ export function migrateLegacySessionEntryState( value: Record, updatedAt: unknown = value.updatedAt, ): Record { const next = { ...value }; + if (hasLegacySessionProviderState(value)) { + for (const [legacy, current] of LEGACY_SESSION_PROVIDER_FIELDS) { + if (typeof value[current] !== "string" && typeof value[legacy] === "string") { + next[current] = value[legacy]; + delete next[legacy]; + } + } + } for (const field of LEGACY_SESSION_ENTRY_STATE_FIELDS) { delete next[field]; } diff --git a/src/config/sessions/session-entry-state-format.ts b/src/config/sessions/session-entry-state-format.ts index 9ee14206bbd8..69788dc7a2a5 100644 --- a/src/config/sessions/session-entry-state-format.ts +++ b/src/config/sessions/session-entry-state-format.ts @@ -1,3 +1,22 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { isCanonicalSessionDeliveryState } from "../../utils/delivery-context.shared.js"; + +export const LEGACY_SESSION_PROVIDER_FIELDS = [ + ["provider", "channel"], + ["lastProvider", "lastChannel"], +] as const; + +export function hasLegacySessionProviderState(value: object): boolean { + return ( + isRecord(value) && + !isCanonicalSessionDeliveryState(value.delivery) && + LEGACY_SESSION_PROVIDER_FIELDS.some( + ([legacy, current]) => + typeof value[legacy] === "string" && typeof value[current] !== "string", + ) + ); +} + export const LEGACY_SESSION_ENTRY_STATE_FIELDS = [ "pendingFinalDeliveryCreatedAt", "pendingFinalDeliveryLastAttemptAt", @@ -21,6 +40,7 @@ export const LEGACY_SESSION_ENTRY_STATE_FIELDS = [ export function hasLegacySessionEntryState(value: object): boolean { return ( ("pendingFinalDelivery" in value && typeof value.pendingFinalDelivery === "boolean") || + hasLegacySessionProviderState(value) || LEGACY_SESSION_ENTRY_STATE_FIELDS.some((field) => Object.hasOwn(value, field)) ); } diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index 99634c936156..360f57736c7a 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -8,6 +8,7 @@ import { import { normalizeSessionConversationLink } from "./conversation-link.js"; import { SessionStoreMigrationRequiredError } from "./migration-required.js"; import { hasLegacySessionEntryState } from "./session-entry-state-format.js"; +import { assertSupportedSessionStoreEntry } from "./supported-session-store.js"; import type { PendingTranscriptRepairState, SessionEntry } from "./types.js"; function normalizeSessionEntryArchiveReason( @@ -28,6 +29,7 @@ function normalizeOptionalTimestamp(value: unknown): number | undefined { /** Removes retired runtime locator fields before a session entry is persisted or returned. */ export function projectCanonicalSessionEntryShape(value: Record): SessionEntry { + assertSupportedSessionStoreEntry(value); if (hasLegacySessionEntryState(value)) { throw new SessionStoreMigrationRequiredError( "Legacy session entry state requires migration; stop the Gateway and run openclaw doctor --fix.", diff --git a/src/config/sessions/supported-session-store.ts b/src/config/sessions/supported-session-store.ts index 297d25845661..735ce9bc3568 100644 --- a/src/config/sessions/supported-session-store.ts +++ b/src/config/sessions/supported-session-store.ts @@ -1,26 +1,13 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { isCanonicalSessionDeliveryState } from "../../utils/delivery-context.shared.js"; +import { SessionStoreMigrationRequiredError } from "./migration-required.js"; export function assertSupportedSessionStoreEntry(entry: unknown): void { if (!isRecord(entry)) { return; } - const canonicalDelivery = isCanonicalSessionDeliveryState(entry.delivery); - const retiredField = ( - [ - ["provider", "channel"], - ["lastProvider", "lastChannel"], - ["room", "groupChannel"], - ] as const - ).find( - ([legacy, current]) => - typeof entry[legacy] === "string" && - typeof entry[current] !== "string" && - (legacy === "room" || !canonicalDelivery), - )?.[0]; - if (retiredField) { - throw new Error( - `Session field "${retiredField}" predates July 2026 and is no longer supported. Preserve the original store and use an older OpenClaw release to migrate it before upgrading.`, + if (typeof entry.room === "string" && typeof entry.groupChannel !== "string") { + throw new SessionStoreMigrationRequiredError( + 'Session field "room" predates July 2026 and is no longer supported. Preserve the original state, install OpenClaw 2026.9.5 and run "openclaw doctor --fix", then upgrade again.', ); } } diff --git a/src/infra/state-migrations.legacy-session-store.test.ts b/src/infra/state-migrations.legacy-session-store.test.ts index 28129f2a6120..2beb26de5f5b 100644 --- a/src/infra/state-migrations.legacy-session-store.test.ts +++ b/src/infra/state-migrations.legacy-session-store.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { readLegacySessionStoreEntries } from "../config/sessions/legacy-store-inspection.js"; import { loadLegacySessionStore, saveLegacySessionStore, @@ -18,7 +19,6 @@ const legacyEntry = { channel: "slack", provider: false, lastProvider: false, - room: false, pendingFinalDeliveryAttemptCount: -1, }; let root: string; @@ -149,6 +149,32 @@ it("normalizes file-era rows and drops malformed entries", async () => { expect(store[MAIN_KEY]?.pluginExtensions).toEqual({ demo: { valid: { ok: true } } }); }); +it("preserves retired room-only source bytes across inspection, loading, and refused writes", async () => { + const store = { [MAIN_KEY]: { sessionId: "session-room", updatedAt: 1, room: "#legacy" } }; + const raw = `${JSON.stringify(store, null, 2)}\n`; + await fs.writeFile(storePath, raw); + expect(() => readLegacySessionStoreEntries({ storePath }, [])).toThrow(/2026\.9\.5/); + expect(() => loadLegacySessionStore(storePath)).toThrow(/2026\.9\.5/); + await expect(saveLegacySessionStore(storePath, store, { skipMaintenance: true })).rejects.toThrow( + /2026\.9\.5/, + ); + expect(await fs.readFile(storePath, "utf8")).toBe(raw); +}); + +it("imports provider-only fields still preserved by the July Doctor writer", async () => { + await writeStore({ + [MAIN_KEY]: { + sessionId: "session-1", + updatedAt: 1, + provider: "slack", + lastProvider: "telegram", + }, + }); + const raw = await fs.readFile(storePath, "utf8"); + expectNormalized(loadLegacySessionStore(storePath), "telegram"); + expect(await fs.readFile(storePath, "utf8")).toBe(raw); +}); + it("normalizes compatibility writes before persistence", async () => { const skillsSnapshot = { prompt: "compact skill prompt", diff --git a/src/infra/state-migrations.legacy-session-store.ts b/src/infra/state-migrations.legacy-session-store.ts index 1288c8e1fcc6..48ec4e4b6a60 100644 --- a/src/infra/state-migrations.legacy-session-store.ts +++ b/src/infra/state-migrations.legacy-session-store.ts @@ -3,8 +3,12 @@ import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { normalizePersistedSessionEntryShape } from "../commands/doctor/shared/session-entry-shape.js"; +import { + migrateLegacySessionEntryState, + normalizePersistedSessionEntryShape, +} from "../commands/doctor/shared/session-entry-shape.js"; import { normalizeRestartRecoveryEntryFields } from "../config/sessions/restart-recovery-state.js"; +import { hasLegacySessionProviderState } from "../config/sessions/session-entry-state-format.js"; import { ensureSessionStorePromptBlobsForPersistence, hydrateSessionStoreSkillPromptRefs, @@ -475,7 +479,16 @@ function mergeExternalOverInternal( } /** Canonicalizes file-era delivery fields before doctor imports a row into SQLite. */ -export function normalizeLegacySessionEntryDelivery(entry: SessionEntry): SessionEntry { +export function normalizeLegacySessionEntryDelivery(entry: SessionEntry): SessionEntry; +export function normalizeLegacySessionEntryDelivery( + entry: Record, +): Record; +export function normalizeLegacySessionEntryDelivery(value: SessionEntry | Record) { + assertSupportedSessionStoreEntry(value); + const entry = + isRecord(value) && hasLegacySessionProviderState(value) + ? migrateLegacySessionEntryState(value) + : value; const legacy = entry as LegacySessionDeliveryEntry; const hasLegacyFields = LEGACY_SESSION_DELIVERY_KEYS.some((key) => key in legacy); if (isCanonicalSessionDeliveryState(entry.delivery) && !hasLegacyFields) {