fix: canonicalize legacy session keys

This commit is contained in:
Peter Steinberger
2026-01-19 05:17:06 +00:00
parent c578fca687
commit 374da34936
8 changed files with 241 additions and 20 deletions

View File

@@ -6,6 +6,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import type { SessionScope } from "../config/sessions/types.js";
import { saveSessionStore } from "../config/sessions.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
@@ -14,6 +15,7 @@ import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
import {
ensureDir,
existsDir,
@@ -27,6 +29,7 @@ import {
export type LegacyStateDetection = {
targetAgentId: string;
targetMainKey: string;
targetScope?: SessionScope;
stateDir: string;
oauthDir: string;
sessions: {
@@ -35,6 +38,7 @@ export type LegacyStateDetection = {
targetDir: string;
targetStorePath: string;
hasLegacy: boolean;
legacyKeys: string[];
};
agentDir: {
legacyDir: string;
@@ -72,34 +76,49 @@ function isLegacyGroupKey(key: string): boolean {
return false;
}
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const raw = key.trim();
function canonicalizeSessionKeyForAgent(params: {
key: string;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): string {
const agentId = normalizeAgentId(params.agentId);
const raw = params.key.trim();
if (!raw) return raw;
if (raw === "global" || raw === "unknown") return raw;
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: { scope: params.scope, mainKey: params.mainKey } },
agentId,
sessionKey: raw,
});
if (canonicalMain !== raw) return canonicalMain;
if (raw.startsWith("agent:")) return raw;
if (raw.toLowerCase().startsWith("subagent:")) {
const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
return `agent:${agentId}:subagent:${rest}`;
}
if (raw.startsWith("group:")) {
const id = raw.slice("group:".length).trim();
if (!id) return raw;
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
return `agent:${normalizeAgentId(agentId)}:${channel}:group:${id}`;
return `agent:${agentId}:${channel}:group:${id}`;
}
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${raw}`;
return `agent:${agentId}:whatsapp:group:${raw}`;
}
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
const remainder = raw.slice("whatsapp:".length).trim();
const cleaned = remainder.replace(/^group:/i, "").trim();
if (cleaned && !isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
return `agent:${agentId}:whatsapp:group:${cleaned}`;
}
}
if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
return `agent:${agentId}:${raw}`;
}
return raw;
return `agent:${agentId}:${raw}`;
}
function pickLatestLegacyDirectEntry(
@@ -140,6 +159,91 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
return normalized;
}
function resolveUpdatedAt(entry: SessionEntryLike): number {
return typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: 0;
}
function mergeSessionEntry(params: {
existing: SessionEntryLike | undefined;
incoming: SessionEntryLike;
preferIncomingOnTie?: boolean;
}): SessionEntryLike {
if (!params.existing) return params.incoming;
const existingUpdated = resolveUpdatedAt(params.existing);
const incomingUpdated = resolveUpdatedAt(params.incoming);
if (incomingUpdated > existingUpdated) return params.incoming;
if (incomingUpdated < existingUpdated) return params.existing;
return params.preferIncomingOnTie ? params.incoming : params.existing;
}
function canonicalizeSessionStore(params: {
store: Record<string, SessionEntryLike>;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): { store: Record<string, SessionEntryLike>; legacyKeys: string[] } {
const canonical: Record<string, SessionEntryLike> = {};
const meta = new Map<string, { isCanonical: boolean; updatedAt: number }>();
const legacyKeys: string[] = [];
for (const [key, entry] of Object.entries(params.store)) {
if (!entry || typeof entry !== "object") continue;
const canonicalKey = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope,
});
const isCanonical = canonicalKey === key;
if (!isCanonical) legacyKeys.push(key);
const existing = canonical[canonicalKey];
if (!existing) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: resolveUpdatedAt(entry) });
continue;
}
const existingMeta = meta.get(canonicalKey);
const incomingUpdated = resolveUpdatedAt(entry);
const existingUpdated = existingMeta?.updatedAt ?? resolveUpdatedAt(existing);
if (incomingUpdated > existingUpdated) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
continue;
}
if (incomingUpdated < existingUpdated) continue;
if (existingMeta?.isCanonical && !isCanonical) continue;
if (!existingMeta?.isCanonical && isCanonical) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
continue;
}
}
return { store: canonical, legacyKeys };
}
function listLegacySessionKeys(params: {
store: Record<string, SessionEntryLike>;
agentId: string;
mainKey: string;
scope?: SessionScope;
}): string[] {
const legacy: string[] = [];
for (const key of Object.keys(params.store)) {
const canonical = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope,
});
if (canonical !== key) legacy.push(key);
}
return legacy;
}
function emptyDirOrMissing(dir: string): boolean {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
@@ -179,6 +283,7 @@ export async function detectLegacyStateMigrations(params: {
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
? rawMainKey.trim()
: DEFAULT_MAIN_KEY;
const targetScope = params.cfg.session?.scope;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
@@ -189,6 +294,18 @@ export async function detectLegacyStateMigrations(params: {
fileExists(sessionsLegacyStorePath) ||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const targetSessionParsed = fileExists(sessionsTargetStorePath)
? readSessionStoreJson5(sessionsTargetStorePath)
: { store: {}, ok: true };
const legacyKeys = targetSessionParsed.ok
? listLegacySessionKeys({
store: targetSessionParsed.store,
agentId: targetAgentId,
mainKey: targetMainKey,
scope: targetScope,
})
: [];
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
@@ -202,6 +319,9 @@ export async function detectLegacyStateMigrations(params: {
if (hasLegacySessions) {
preview.push(`- Sessions: ${sessionsLegacyDir}${sessionsTargetDir}`);
}
if (legacyKeys.length > 0) {
preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
}
if (hasLegacyAgentDir) {
preview.push(`- Agent dir: ${legacyAgentDir}${targetAgentDir}`);
}
@@ -212,6 +332,7 @@ export async function detectLegacyStateMigrations(params: {
return {
targetAgentId,
targetMainKey,
targetScope,
stateDir,
oauthDir,
sessions: {
@@ -219,7 +340,8 @@ export async function detectLegacyStateMigrations(params: {
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions,
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
legacyKeys,
},
agentDir: {
legacyDir: legacyAgentDir,
@@ -254,17 +376,27 @@ async function migrateLegacySessions(
const legacyStore = legacyParsed.store;
const targetStore = targetParsed.store;
const normalizedLegacy: Record<string, SessionEntryLike> = {};
for (const [key, entry] of Object.entries(legacyStore)) {
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
if (!nextKey) continue;
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
}
const canonicalizedTarget = canonicalizeSessionStore({
store: targetStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope,
});
const canonicalizedLegacy = canonicalizeSessionStore({
store: legacyStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope,
});
const merged: Record<string, SessionEntryLike> = {
...normalizedLegacy,
...targetStore,
};
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
merged[key] = mergeSessionEntry({
existing: merged[key],
incoming: entry,
preferIncomingOnTie: false,
});
}
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
@@ -285,7 +417,7 @@ async function migrateLegacySessions(
}
if (
legacyParsed.ok &&
(legacyParsed.ok || targetParsed.ok) &&
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
) {
const normalized: Record<string, SessionEntry> = {};
@@ -296,6 +428,11 @@ async function migrateLegacySessions(
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
if (canonicalizedTarget.legacyKeys.length > 0) {
changes.push(
`Canonicalized ${canonicalizedTarget.legacyKeys.length} legacy session key(s)`,
);
}
}
const entries = safeReadDir(detected.sessions.legacyDir);