fix: canonicalize legacy session keys
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||||
|
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ describe("doctor legacy state migrations", () => {
|
|||||||
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
) as Record<string, { sessionId: string }>;
|
) as Record<string, { sessionId: string }>;
|
||||||
expect(store["agent:main:main"]?.sessionId).toBe("b");
|
expect(store["agent:main:main"]?.sessionId).toBe("b");
|
||||||
|
expect(store["agent:main:+1555"]?.sessionId).toBe("a");
|
||||||
|
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
|
||||||
|
expect(store["+1555"]).toBeUndefined();
|
||||||
|
expect(store["+1666"]).toBeUndefined();
|
||||||
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
|
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
|
||||||
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
|
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
|
||||||
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
|
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
|
||||||
@@ -229,4 +233,73 @@ describe("doctor legacy state migrations", () => {
|
|||||||
expect(store["agent:main:work"]?.sessionId).toBe("b");
|
expect(store["agent:main:work"]?.sessionId).toBe("b");
|
||||||
expect(store["agent:main:main"]).toBeUndefined();
|
expect(store["agent:main:main"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("canonicalizes legacy main keys inside the target sessions store", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||||
|
main: { sessionId: "legacy", updatedAt: 10 },
|
||||||
|
"agent:main:main": { sessionId: "fresh", updatedAt: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = await detectLegacyStateMigrations({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||||
|
|
||||||
|
const store = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, { sessionId: string }>;
|
||||||
|
expect(store["main"]).toBeUndefined();
|
||||||
|
expect(store["agent:main:main"]?.sessionId).toBe("fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the newest entry when collapsing main aliases", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = { session: { mainKey: "work" } };
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||||
|
"agent:main:main": { sessionId: "legacy", updatedAt: 50 },
|
||||||
|
"agent:main:work": { sessionId: "canonical", updatedAt: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = await detectLegacyStateMigrations({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||||
|
|
||||||
|
const store = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, { sessionId: string }>;
|
||||||
|
expect(store["agent:main:work"]?.sessionId).toBe("legacy");
|
||||||
|
expect(store["agent:main:main"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-migrates when only target sessions contain legacy keys", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||||
|
main: { sessionId: "legacy", updatedAt: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = { info: vi.fn(), warn: vi.fn() };
|
||||||
|
|
||||||
|
const result = await autoMigrateLegacyState({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, { sessionId: string }>;
|
||||||
|
expect(result.migrated).toBe(true);
|
||||||
|
expect(log.info).toHaveBeenCalled();
|
||||||
|
expect(store["main"]).toBeUndefined();
|
||||||
|
expect(store["agent:main:main"]?.sessionId).toBe("legacy");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -303,6 +304,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||||
import type { SessionEntry } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
import type { SessionScope } from "../config/sessions/types.js";
|
||||||
import { saveSessionStore } from "../config/sessions.js";
|
import { saveSessionStore } from "../config/sessions.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
DEFAULT_MAIN_KEY,
|
DEFAULT_MAIN_KEY,
|
||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
|
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
|
||||||
import {
|
import {
|
||||||
ensureDir,
|
ensureDir,
|
||||||
existsDir,
|
existsDir,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
export type LegacyStateDetection = {
|
export type LegacyStateDetection = {
|
||||||
targetAgentId: string;
|
targetAgentId: string;
|
||||||
targetMainKey: string;
|
targetMainKey: string;
|
||||||
|
targetScope?: SessionScope;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
oauthDir: string;
|
oauthDir: string;
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -35,6 +38,7 @@ export type LegacyStateDetection = {
|
|||||||
targetDir: string;
|
targetDir: string;
|
||||||
targetStorePath: string;
|
targetStorePath: string;
|
||||||
hasLegacy: boolean;
|
hasLegacy: boolean;
|
||||||
|
legacyKeys: string[];
|
||||||
};
|
};
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: string;
|
legacyDir: string;
|
||||||
@@ -72,34 +76,49 @@ function isLegacyGroupKey(key: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
|
function canonicalizeSessionKeyForAgent(params: {
|
||||||
const raw = key.trim();
|
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) 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.startsWith("agent:")) return raw;
|
||||||
if (raw.toLowerCase().startsWith("subagent:")) {
|
if (raw.toLowerCase().startsWith("subagent:")) {
|
||||||
const rest = raw.slice("subagent:".length);
|
const rest = raw.slice("subagent:".length);
|
||||||
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
|
return `agent:${agentId}:subagent:${rest}`;
|
||||||
}
|
}
|
||||||
if (raw.startsWith("group:")) {
|
if (raw.startsWith("group:")) {
|
||||||
const id = raw.slice("group:".length).trim();
|
const id = raw.slice("group:".length).trim();
|
||||||
if (!id) return raw;
|
if (!id) return raw;
|
||||||
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
|
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")) {
|
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")) {
|
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
|
||||||
const remainder = raw.slice("whatsapp:".length).trim();
|
const remainder = raw.slice("whatsapp:".length).trim();
|
||||||
const cleaned = remainder.replace(/^group:/i, "").trim();
|
const cleaned = remainder.replace(/^group:/i, "").trim();
|
||||||
if (cleaned && !isSurfaceGroupKey(raw)) {
|
if (cleaned && !isSurfaceGroupKey(raw)) {
|
||||||
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
|
return `agent:${agentId}:whatsapp:group:${cleaned}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isSurfaceGroupKey(raw)) {
|
if (isSurfaceGroupKey(raw)) {
|
||||||
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
return `agent:${agentId}:${raw}`;
|
||||||
}
|
}
|
||||||
return raw;
|
return `agent:${agentId}:${raw}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickLatestLegacyDirectEntry(
|
function pickLatestLegacyDirectEntry(
|
||||||
@@ -140,6 +159,91 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
|
|||||||
return normalized;
|
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 {
|
function emptyDirOrMissing(dir: string): boolean {
|
||||||
if (!existsDir(dir)) return true;
|
if (!existsDir(dir)) return true;
|
||||||
return safeReadDir(dir).length === 0;
|
return safeReadDir(dir).length === 0;
|
||||||
@@ -179,6 +283,7 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
|
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
|
||||||
? rawMainKey.trim()
|
? rawMainKey.trim()
|
||||||
: DEFAULT_MAIN_KEY;
|
: DEFAULT_MAIN_KEY;
|
||||||
|
const targetScope = params.cfg.session?.scope;
|
||||||
|
|
||||||
const sessionsLegacyDir = path.join(stateDir, "sessions");
|
const sessionsLegacyDir = path.join(stateDir, "sessions");
|
||||||
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
|
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
|
||||||
@@ -189,6 +294,18 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
fileExists(sessionsLegacyStorePath) ||
|
fileExists(sessionsLegacyStorePath) ||
|
||||||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
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 legacyAgentDir = path.join(stateDir, "agent");
|
||||||
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
|
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
|
||||||
const hasLegacyAgentDir = existsDir(legacyAgentDir);
|
const hasLegacyAgentDir = existsDir(legacyAgentDir);
|
||||||
@@ -202,6 +319,9 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
if (hasLegacySessions) {
|
if (hasLegacySessions) {
|
||||||
preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
|
preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
|
||||||
}
|
}
|
||||||
|
if (legacyKeys.length > 0) {
|
||||||
|
preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
|
||||||
|
}
|
||||||
if (hasLegacyAgentDir) {
|
if (hasLegacyAgentDir) {
|
||||||
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
|
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
|
||||||
}
|
}
|
||||||
@@ -212,6 +332,7 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
return {
|
return {
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
targetMainKey,
|
targetMainKey,
|
||||||
|
targetScope,
|
||||||
stateDir,
|
stateDir,
|
||||||
oauthDir,
|
oauthDir,
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -219,7 +340,8 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
legacyStorePath: sessionsLegacyStorePath,
|
legacyStorePath: sessionsLegacyStorePath,
|
||||||
targetDir: sessionsTargetDir,
|
targetDir: sessionsTargetDir,
|
||||||
targetStorePath: sessionsTargetStorePath,
|
targetStorePath: sessionsTargetStorePath,
|
||||||
hasLegacy: hasLegacySessions,
|
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
|
||||||
|
legacyKeys,
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: legacyAgentDir,
|
legacyDir: legacyAgentDir,
|
||||||
@@ -254,17 +376,27 @@ async function migrateLegacySessions(
|
|||||||
const legacyStore = legacyParsed.store;
|
const legacyStore = legacyParsed.store;
|
||||||
const targetStore = targetParsed.store;
|
const targetStore = targetParsed.store;
|
||||||
|
|
||||||
const normalizedLegacy: Record<string, SessionEntryLike> = {};
|
const canonicalizedTarget = canonicalizeSessionStore({
|
||||||
for (const [key, entry] of Object.entries(legacyStore)) {
|
store: targetStore,
|
||||||
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
|
agentId: detected.targetAgentId,
|
||||||
if (!nextKey) continue;
|
mainKey: detected.targetMainKey,
|
||||||
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
|
scope: detected.targetScope,
|
||||||
}
|
});
|
||||||
|
const canonicalizedLegacy = canonicalizeSessionStore({
|
||||||
|
store: legacyStore,
|
||||||
|
agentId: detected.targetAgentId,
|
||||||
|
mainKey: detected.targetMainKey,
|
||||||
|
scope: detected.targetScope,
|
||||||
|
});
|
||||||
|
|
||||||
const merged: Record<string, SessionEntryLike> = {
|
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
|
||||||
...normalizedLegacy,
|
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
|
||||||
...targetStore,
|
merged[key] = mergeSessionEntry({
|
||||||
};
|
existing: merged[key],
|
||||||
|
incoming: entry,
|
||||||
|
preferIncomingOnTie: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const mainKey = buildAgentMainSessionKey({
|
const mainKey = buildAgentMainSessionKey({
|
||||||
agentId: detected.targetAgentId,
|
agentId: detected.targetAgentId,
|
||||||
@@ -285,7 +417,7 @@ async function migrateLegacySessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
legacyParsed.ok &&
|
(legacyParsed.ok || targetParsed.ok) &&
|
||||||
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
|
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
|
||||||
) {
|
) {
|
||||||
const normalized: Record<string, SessionEntry> = {};
|
const normalized: Record<string, SessionEntry> = {};
|
||||||
@@ -296,6 +428,11 @@ async function migrateLegacySessions(
|
|||||||
}
|
}
|
||||||
await saveSessionStore(detected.sessions.targetStorePath, normalized);
|
await saveSessionStore(detected.sessions.targetStorePath, normalized);
|
||||||
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
|
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);
|
const entries = safeReadDir(detected.sessions.legacyDir);
|
||||||
|
|||||||
Reference in New Issue
Block a user