fix(sessions): retain Doctor repair for July provider aliases (#163171)

* fix(sessions): retain Doctor repair for July provider aliases

* fix(doctor): preserve validated session types during verification

* test(doctor): cover supported July provider imports

* fix(doctor): migrate provider aliases before delivery normalization

* refactor(doctor): keep migration input immutable
This commit is contained in:
Peter Steinberger 2026-10-02 06:33:42 -07:00 • committed by GitHub
parent 05c3782128
commit f1be74485a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 250 additions and 49 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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/);
},
);

View file

@ -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(

View file

@ -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: {

View file

@ -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<boolean>`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<typeof splitSessionEntrySnapshots>["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),
);

View file

@ -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<string, unknown>,
updatedAt: unknown = value.updatedAt,
): Record<string, unknown> {
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];
}

View file

@ -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))
);
}

View file

@ -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<string, unknown>): SessionEntry {
assertSupportedSessionStoreEntry(value);
if (hasLegacySessionEntryState(value)) {
throw new SessionStoreMigrationRequiredError(
"Legacy session entry state requires migration; stop the Gateway and run openclaw doctor --fix.",

View file

@ -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.',
);
}
}

View file

@ -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",

View file

@ -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<string, unknown>,
): Record<string, unknown>;
export function normalizeLegacySessionEntryDelivery(value: SessionEntry | Record<string, unknown>) {
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) {