mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
05c3782128
commit
f1be74485a
12 changed files with 250 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue