mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 17:38:09 +00:00
fix: harden legacy session SQLite migration
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
// Assertions for upgrade-survivor E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const command = process.argv[2];
|
||||
const SCENARIOS = new Set([
|
||||
"base",
|
||||
@@ -23,6 +26,10 @@ const PERSONA_FILES = new Map([
|
||||
["MEMORY.md", "# Existing Memory\n\nUpgrade reports came from real users.\n"],
|
||||
]);
|
||||
|
||||
const LEGACY_SESSION_MAIN_ID = "upgrade-main-session";
|
||||
const LEGACY_SESSION_DIRECT_ID = "upgrade-direct-session";
|
||||
const LEGACY_SESSION_GROUP_ID = "upgrade-group-session";
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
@@ -83,6 +90,71 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionRowsFromAgentSqlite(stateDir, agentId = "main") {
|
||||
const dbPath = path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite");
|
||||
assert(fs.existsSync(dbPath), `agent SQLite session database missing: ${dbPath}`);
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ? ORDER BY key ASC")
|
||||
.all("session_entries");
|
||||
return Object.fromEntries(
|
||||
rows.map((row) => {
|
||||
assert(typeof row.key === "string", "session SQLite row key was not a string");
|
||||
assert(
|
||||
typeof row.value_json === "string",
|
||||
`session SQLite row ${String(row.key)} had no JSON payload`,
|
||||
);
|
||||
return [row.key, JSON.parse(row.value_json)];
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function seedLegacySessionMetadata(stateDir) {
|
||||
const legacySessionsDir = path.join(stateDir, "sessions");
|
||||
writeJson(path.join(legacySessionsDir, "sessions.json"), {
|
||||
main: {
|
||||
sessionId: LEGACY_SESSION_MAIN_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000000,
|
||||
skillsSnapshot: {
|
||||
prompt: "legacy prompt survives as metadata",
|
||||
resolvedSkills: [
|
||||
{
|
||||
name: "legacy-heavy-skill-cache",
|
||||
filePath: "/tmp/openclaw-old-package/skills/legacy-heavy-skill-cache/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"+15551234567": {
|
||||
sessionId: LEGACY_SESSION_DIRECT_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000100,
|
||||
},
|
||||
"slack:channel:CUPGRADE": {
|
||||
sessionId: LEGACY_SESSION_GROUP_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000200,
|
||||
lastChannel: "slack",
|
||||
lastTo: "CUPGRADE",
|
||||
},
|
||||
});
|
||||
write(path.join(legacySessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`), '{"type":"main"}\n');
|
||||
write(path.join(legacySessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`), '{"type":"direct"}\n');
|
||||
write(path.join(legacySessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`), '{"type":"group"}\n');
|
||||
}
|
||||
|
||||
function getScenario() {
|
||||
const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
|
||||
assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`);
|
||||
@@ -139,6 +211,7 @@ function seedState() {
|
||||
agentId: "main",
|
||||
title: "Existing user session",
|
||||
});
|
||||
seedLegacySessionMetadata(stateDir);
|
||||
|
||||
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
for (const plugin of ["discord", "telegram", "whatsapp"]) {
|
||||
@@ -357,12 +430,15 @@ function assertStateSurvived() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const scenario = getScenario();
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
assert(fs.existsSync(path.join(workspace, "IDENTITY.md")), "workspace identity file missing");
|
||||
assert(
|
||||
fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")),
|
||||
"legacy session file missing",
|
||||
);
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
if (stage !== "baseline") {
|
||||
assertSessionMetadataMigratedToSqlite(stateDir);
|
||||
}
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
if (stage === "baseline") {
|
||||
if (fs.existsSync(legacyRuntimeRoot)) {
|
||||
@@ -406,6 +482,59 @@ function assertStateSurvived() {
|
||||
}
|
||||
}
|
||||
|
||||
function assertSessionMetadataMigratedToSqlite(stateDir) {
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const agentSessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
assert(
|
||||
!fs.existsSync(legacyStorePath),
|
||||
`legacy sessions.json survived migration: ${legacyStorePath}`,
|
||||
);
|
||||
for (const sessionId of [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
]) {
|
||||
assert(
|
||||
fs.existsSync(path.join(agentSessionsDir, `${sessionId}.jsonl`)),
|
||||
`legacy session transcript was not moved for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const store = readSessionRowsFromAgentSqlite(stateDir);
|
||||
const main = store["agent:main:main"];
|
||||
const direct = store["agent:main:+15551234567"];
|
||||
const group = store["agent:main:slack:channel:cupgrade"];
|
||||
assert(main?.sessionId === LEGACY_SESSION_MAIN_ID, "main legacy session row missing from SQLite");
|
||||
assert(
|
||||
direct?.sessionId === LEGACY_SESSION_DIRECT_ID,
|
||||
"direct legacy session row missing from SQLite",
|
||||
);
|
||||
assert(
|
||||
group?.sessionId === LEGACY_SESSION_GROUP_ID,
|
||||
"channel legacy session row missing from SQLite",
|
||||
);
|
||||
assert(
|
||||
main?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
"main legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
direct?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
"direct legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
group?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
"channel legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
main.skillsSnapshot?.prompt === "legacy prompt survives as metadata",
|
||||
"legacy session metadata prompt was not preserved",
|
||||
);
|
||||
assert(
|
||||
main.skillsSnapshot?.resolvedSkills === undefined,
|
||||
"heavy resolvedSkills cache was persisted into SQLite session metadata",
|
||||
);
|
||||
}
|
||||
|
||||
function readInstalledPluginIndex() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const index = readPluginInstallIndex({ stateDir });
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSqliteSessionStoreDatabasePath } from "../config/sessions/store-sqlite.js";
|
||||
import { loadSessionStore } from "../config/sessions/store.js";
|
||||
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
writePersistedInstalledPluginIndex,
|
||||
} from "../plugins/installed-plugin-index-store.js";
|
||||
import type { InstalledPluginInstallRecordInfo } from "../plugins/installed-plugin-index.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { loadTaskFlowRegistryStateFromSqlite } from "../tasks/task-flow-registry.store.sqlite.js";
|
||||
import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js";
|
||||
@@ -245,6 +247,7 @@ async function runTelegramAllowFromMigration(params: { root: string; cfg: OpenCl
|
||||
afterEach(async () => {
|
||||
resetAutoMigrateLegacyStateForTest();
|
||||
resetAutoMigrateLegacyStateDirForTest();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
setMaxPluginStateEntriesPerPluginForTests();
|
||||
resetPluginStateStoreForTests();
|
||||
@@ -534,7 +537,7 @@ async function withStateDir<T>(root: string, run: () => Promise<T>): Promise<T>
|
||||
function readSessionsStore(targetDir: string) {
|
||||
return loadSessionStore(path.join(targetDir, "sessions.json"), { skipCache: true }) as Record<
|
||||
string,
|
||||
{ sessionId: string }
|
||||
{ sessionId: string; sessionFile?: string }
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -629,7 +632,7 @@ describe("doctor legacy state migrations", () => {
|
||||
result: Awaited<ReturnType<typeof runLegacyStateMigrations>>;
|
||||
targetDir: string;
|
||||
legacySessionsDir: string;
|
||||
store: Record<string, { sessionId: string }>;
|
||||
store: Record<string, { sessionId: string; sessionFile?: string }>;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -638,8 +641,16 @@ describe("doctor legacy state migrations", () => {
|
||||
const legacySessionsDir = writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": { sessionId: "a", updatedAt: 10 },
|
||||
"+1666": { sessionId: "b", updatedAt: 20 },
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
sessionFile: path.join(root, "sessions", "a.jsonl"),
|
||||
updatedAt: 10,
|
||||
},
|
||||
"+1666": {
|
||||
sessionId: "b",
|
||||
sessionFile: path.join(root, "sessions", "b.jsonl"),
|
||||
updatedAt: 20,
|
||||
},
|
||||
"slack:channel:C123": { sessionId: "c", updatedAt: 30 },
|
||||
"group:abc": { sessionId: "d", updatedAt: 40 },
|
||||
"subagent:xyz": { sessionId: "e", updatedAt: 50 },
|
||||
@@ -672,8 +683,11 @@ describe("doctor legacy state migrations", () => {
|
||||
expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false);
|
||||
|
||||
expect(store["agent:main:main"]?.sessionId).toBe("b");
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(path.join(targetDir, "b.jsonl"));
|
||||
expect(store["agent:main:+1555"]?.sessionId).toBe("a");
|
||||
expect(store["agent:main:+1555"]?.sessionFile).toBe(path.join(targetDir, "a.jsonl"));
|
||||
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
|
||||
expect(store["agent:main:+1666"]?.sessionFile).toBe(path.join(targetDir, "b.jsonl"));
|
||||
expect(store["+1555"]).toBeUndefined();
|
||||
expect(store["+1666"]).toBeUndefined();
|
||||
expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("c");
|
||||
@@ -681,6 +695,360 @@ describe("doctor legacy state migrations", () => {
|
||||
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
|
||||
});
|
||||
|
||||
it("keeps migrated sessionFile metadata aligned with conflicted transcript moves", async () => {
|
||||
const root = await makeTempRoot();
|
||||
writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
updatedAt: 10,
|
||||
},
|
||||
"+1666": {
|
||||
sessionId: "b",
|
||||
sessionFile: "b.jsonl",
|
||||
updatedAt: 20,
|
||||
},
|
||||
"+1777": {
|
||||
sessionId: "legacy-collision",
|
||||
sessionFile: "a.legacy-123.jsonl",
|
||||
updatedAt: 15,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"a.jsonl": "legacy transcript",
|
||||
"b.jsonl": "legacy relative transcript",
|
||||
"a.legacy-123.jsonl": "legacy default-destination transcript",
|
||||
},
|
||||
});
|
||||
const legacyMtime = new Date("2024-01-02T03:04:05.000Z");
|
||||
const legacyRelativeMtime = new Date("2024-01-03T03:04:05.000Z");
|
||||
fs.utimesSync(path.join(root, "sessions", "a.jsonl"), legacyMtime, legacyMtime);
|
||||
fs.utimesSync(path.join(root, "sessions", "b.jsonl"), legacyRelativeMtime, legacyRelativeMtime);
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetDir, "a.jsonl"), "existing transcript", "utf-8");
|
||||
fs.writeFileSync(path.join(targetDir, "b.jsonl"), "existing relative transcript", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
const movedPath = path.join(targetDir, "a.legacy-123-1.jsonl");
|
||||
const movedCollisionPath = path.join(targetDir, "a.legacy-123.jsonl");
|
||||
const movedRelativePath = path.join(targetDir, "b.legacy-123.jsonl");
|
||||
const store = readSessionsStore(targetDir);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "a.jsonl"), "utf-8")).toBe("existing transcript");
|
||||
expect(fs.readFileSync(path.join(targetDir, "b.jsonl"), "utf-8")).toBe(
|
||||
"existing relative transcript",
|
||||
);
|
||||
expect(fs.readFileSync(movedPath, "utf-8")).toBe("legacy transcript");
|
||||
expect(fs.readFileSync(movedCollisionPath, "utf-8")).toBe(
|
||||
"legacy default-destination transcript",
|
||||
);
|
||||
expect(fs.readFileSync(movedRelativePath, "utf-8")).toBe("legacy relative transcript");
|
||||
expect(Math.abs(fs.statSync(movedPath).mtimeMs - legacyMtime.getTime())).toBeLessThan(5);
|
||||
expect(
|
||||
Math.abs(fs.statSync(movedRelativePath).mtimeMs - legacyRelativeMtime.getTime()),
|
||||
).toBeLessThan(5);
|
||||
expect(store["agent:main:+1555"]?.sessionFile).toBe(movedPath);
|
||||
expect(store["agent:main:+1666"]?.sessionFile).toBe(movedRelativePath);
|
||||
expect(store["agent:main:+1777"]?.sessionFile).toBe(movedCollisionPath);
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(movedRelativePath);
|
||||
});
|
||||
|
||||
it("keeps case-only target transcript names from sharing a moved legacy path", async () => {
|
||||
const root = await makeTempRoot();
|
||||
writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1888": {
|
||||
sessionId: "Case",
|
||||
sessionFile: "Case.jsonl",
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"Case.jsonl": "legacy transcript",
|
||||
},
|
||||
});
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetDir, "case.jsonl"), "existing target transcript", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
const movedPath = path.join(targetDir, "Case.legacy-123.jsonl");
|
||||
const store = readSessionsStore(targetDir);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "case.jsonl"), "utf-8")).toBe(
|
||||
"existing target transcript",
|
||||
);
|
||||
expect(fs.readFileSync(movedPath, "utf-8")).toBe("legacy transcript");
|
||||
expect(store["agent:main:+1888"]?.sessionFile).toBe(movedPath);
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(movedPath);
|
||||
});
|
||||
|
||||
it("rewrites case-mismatched legacy sessionFile metadata when the source match is unique", async () => {
|
||||
const root = await makeTempRoot();
|
||||
writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1888": {
|
||||
sessionId: "case-mismatch",
|
||||
sessionFile: "casemismatch.jsonl",
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"CaseMismatch.jsonl": "case-mismatched transcript",
|
||||
},
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
const movedPath = path.join(targetDir, "CaseMismatch.jsonl");
|
||||
const store = readSessionsStore(targetDir);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.readFileSync(movedPath, "utf-8")).toBe("case-mismatched transcript");
|
||||
expect(store["agent:main:+1888"]?.sessionFile).toBe(movedPath);
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(movedPath);
|
||||
});
|
||||
|
||||
it("does not rewrite newer target session rows to older copied legacy transcripts", async () => {
|
||||
const root = await makeTempRoot();
|
||||
writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"a.jsonl": "older legacy transcript",
|
||||
},
|
||||
});
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetDir, "a.jsonl"), "newer target transcript", "utf-8");
|
||||
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||
"+1555": { sessionId: "a", updatedAt: 20 },
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
const movedPath = path.join(targetDir, "a.legacy-123.jsonl");
|
||||
const store = readSessionsStore(targetDir);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "a.jsonl"), "utf-8")).toBe(
|
||||
"newer target transcript",
|
||||
);
|
||||
expect(fs.readFileSync(movedPath, "utf-8")).toBe("older legacy transcript");
|
||||
expect(store["agent:main:+1555"]?.sessionId).toBe("a");
|
||||
expect(store["agent:main:+1555"]?.sessionFile).toBeUndefined();
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(movedPath);
|
||||
});
|
||||
|
||||
it("keeps legacy transcripts retryable when SQLite session import fails", async () => {
|
||||
const root = await makeTempRoot();
|
||||
writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
sessionFile: path.join(root, "sessions", "a.jsonl"),
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"a.jsonl": "legacy transcript",
|
||||
},
|
||||
});
|
||||
const dbPath = path.join(root, "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
fs.mkdirSync(dbPath, { recursive: true });
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
await expect(runLegacyStateMigrations({ detected, now: () => 123 })).rejects.toThrow();
|
||||
expect(fs.readFileSync(path.join(root, "sessions", "a.jsonl"), "utf-8")).toBe(
|
||||
"legacy transcript",
|
||||
);
|
||||
expect(fs.existsSync(path.join(root, "agents", "main", "sessions", "a.jsonl"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(root, "sessions", "sessions.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps legacy transcripts retryable after a partial SQLite session import artifact", async () => {
|
||||
const root = await makeTempRoot();
|
||||
writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
sessionFile: path.join(root, "sessions", "a.jsonl"),
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"a.jsonl": "legacy transcript",
|
||||
},
|
||||
});
|
||||
const targetStorePath = path.join(root, "agents", "main", "sessions", "sessions.json");
|
||||
const databasePath = resolveSqliteSessionStoreDatabasePath(targetStorePath);
|
||||
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
||||
fs.writeFileSync(databasePath, "partial sqlite artifact from failed import", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
await expect(runLegacyStateMigrations({ detected, now: () => 123 })).rejects.toThrow();
|
||||
expect(fs.readFileSync(databasePath, "utf-8")).toBe(
|
||||
"partial sqlite artifact from failed import",
|
||||
);
|
||||
expect(fs.readFileSync(path.join(root, "sessions", "a.jsonl"), "utf-8")).toBe(
|
||||
"legacy transcript",
|
||||
);
|
||||
expect(fs.existsSync(path.join(root, "agents", "main", "sessions", "a.jsonl"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(root, "sessions", "sessions.json"))).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(root, "sessions", ".openclaw-session-migration-plan.json")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retries session metadata import from a persisted transcript move plan", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const legacyDir = writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
sessionFile: path.join(root, "sessions", "a.jsonl"),
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"a.jsonl": "legacy transcript",
|
||||
},
|
||||
});
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetDir, "a.jsonl"), "existing target transcript", "utf-8");
|
||||
const movedPath = path.join(targetDir, "a.legacy-123.jsonl");
|
||||
fs.renameSync(path.join(legacyDir, "a.jsonl"), movedPath);
|
||||
writeJson5(path.join(legacyDir, ".openclaw-session-migration-plan.json"), {
|
||||
version: 1,
|
||||
moves: [
|
||||
{
|
||||
from: path.join(legacyDir, "a.jsonl"),
|
||||
to: movedPath,
|
||||
name: "a.jsonl",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 456,
|
||||
});
|
||||
|
||||
const store = readSessionsStore(targetDir);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "a.jsonl"), "utf-8")).toBe(
|
||||
"existing target transcript",
|
||||
);
|
||||
expect(fs.readFileSync(movedPath, "utf-8")).toBe("legacy transcript");
|
||||
expect(store["agent:main:+1555"]?.sessionFile).toBe(movedPath);
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(movedPath);
|
||||
expect(fs.existsSync(path.join(legacyDir, ".openclaw-session-migration-plan.json"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overwrite newer target transcripts when replaying a persisted move plan", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const legacyDir = writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
"+1555": {
|
||||
sessionId: "a",
|
||||
sessionFile: path.join(root, "sessions", "a.jsonl"),
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
transcripts: {
|
||||
"a.jsonl": "legacy transcript",
|
||||
},
|
||||
});
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const stalePlannedPath = path.join(targetDir, "a.legacy-123.jsonl");
|
||||
fs.writeFileSync(stalePlannedPath, "newer target transcript", "utf-8");
|
||||
writeJson5(path.join(legacyDir, ".openclaw-session-migration-plan.json"), {
|
||||
version: 1,
|
||||
moves: [
|
||||
{
|
||||
from: path.join(legacyDir, "a.jsonl"),
|
||||
to: stalePlannedPath,
|
||||
name: "a.jsonl",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 456,
|
||||
});
|
||||
|
||||
const movedPath = path.join(targetDir, "a.legacy-456.jsonl");
|
||||
const store = readSessionsStore(targetDir);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.readFileSync(stalePlannedPath, "utf-8")).toBe("newer target transcript");
|
||||
expect(fs.readFileSync(movedPath, "utf-8")).toBe("legacy transcript");
|
||||
expect(store["agent:main:+1555"]?.sessionFile).toBe(movedPath);
|
||||
expect(store["agent:main:main"]?.sessionFile).toBe(movedPath);
|
||||
});
|
||||
|
||||
it("imports detected non-default configured session stores into SQLite", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const storeTemplate = path.join(root, "stores", "sessions-{agentId}.json");
|
||||
|
||||
@@ -180,6 +180,7 @@ type DetectedPluginDoctorStateMigrationPlan = {
|
||||
|
||||
const PLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const;
|
||||
const TASK_STATE_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const;
|
||||
const LEGACY_SESSION_FILE_MOVE_PLAN_NAME = ".openclaw-session-migration-plan.json";
|
||||
const LEGACY_DELIVERY_QUEUE_DIRS = [
|
||||
{ label: "outbound delivery queue", queueName: "outbound", dirName: "delivery-queue" },
|
||||
{ label: "session delivery queue", queueName: "session", dirName: "session-delivery-queue" },
|
||||
@@ -1931,6 +1932,342 @@ function mergeSessionEntry(params: {
|
||||
return params.preferIncomingOnTie ? params.incoming : params.existing;
|
||||
}
|
||||
|
||||
function rewriteLegacySessionFilePaths(params: {
|
||||
store: Record<string, SessionEntryLike>;
|
||||
legacyDir: string;
|
||||
movedFiles: MovedSessionFiles;
|
||||
}): Record<string, SessionEntryLike> {
|
||||
const rewritten: Record<string, SessionEntryLike> = {};
|
||||
const legacyDir = path.resolve(params.legacyDir);
|
||||
for (const [key, entry] of Object.entries(params.store)) {
|
||||
const rawSessionFile = (entry as { sessionFile?: unknown }).sessionFile;
|
||||
const movedSessionFile =
|
||||
typeof rawSessionFile === "string"
|
||||
? lookupMovedSessionFile(
|
||||
params.movedFiles,
|
||||
path.isAbsolute(rawSessionFile)
|
||||
? path.resolve(rawSessionFile)
|
||||
: path.resolve(legacyDir, rawSessionFile),
|
||||
)
|
||||
: resolveMovedSessionFileFromSessionId({
|
||||
entry,
|
||||
legacyDir,
|
||||
movedFiles: params.movedFiles,
|
||||
});
|
||||
if (!movedSessionFile) {
|
||||
rewritten[key] = entry;
|
||||
continue;
|
||||
}
|
||||
rewritten[key] = {
|
||||
...entry,
|
||||
sessionFile: movedSessionFile,
|
||||
};
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
function resolveMovedSessionFileFromSessionId(params: {
|
||||
entry: SessionEntryLike;
|
||||
legacyDir: string;
|
||||
movedFiles: MovedSessionFiles;
|
||||
}): string | undefined {
|
||||
const rawSessionId = (params.entry as { sessionId?: unknown }).sessionId;
|
||||
if (typeof rawSessionId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const sessionId = validateSessionId(rawSessionId);
|
||||
return lookupMovedSessionFile(
|
||||
params.movedFiles,
|
||||
path.join(params.legacyDir, `${sessionId}.jsonl`),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type LegacySessionFileMove = {
|
||||
from: string;
|
||||
to: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type MovedSessionFiles = {
|
||||
exact: Map<string, string>;
|
||||
folded: Map<string, string>;
|
||||
ambiguousFolded: Set<string>;
|
||||
};
|
||||
|
||||
// Case-insensitive filesystems can report a source file with casing that differs
|
||||
// from stored sessionFile metadata. Folded aliases are safe only when exactly
|
||||
// one moved source owns that spelling; otherwise keep lookup exact.
|
||||
function buildMovedSessionFiles(moves: LegacySessionFileMove[]): MovedSessionFiles {
|
||||
const foldedCounts = new Map<string, number>();
|
||||
for (const move of moves) {
|
||||
const folded = sessionMovePathKey(move.from);
|
||||
foldedCounts.set(folded, (foldedCounts.get(folded) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const movedFiles: MovedSessionFiles = {
|
||||
exact: new Map(),
|
||||
folded: new Map(),
|
||||
ambiguousFolded: new Set(),
|
||||
};
|
||||
for (const [folded, count] of foldedCounts) {
|
||||
if (count > 1) {
|
||||
movedFiles.ambiguousFolded.add(folded);
|
||||
}
|
||||
}
|
||||
return movedFiles;
|
||||
}
|
||||
|
||||
function recordMovedSessionFile(params: {
|
||||
movedFiles: MovedSessionFiles;
|
||||
move: LegacySessionFileMove;
|
||||
}): void {
|
||||
const exact = path.resolve(params.move.from);
|
||||
const folded = sessionMovePathKey(params.move.from);
|
||||
params.movedFiles.exact.set(exact, params.move.to);
|
||||
if (!params.movedFiles.ambiguousFolded.has(folded)) {
|
||||
params.movedFiles.folded.set(folded, params.move.to);
|
||||
}
|
||||
}
|
||||
|
||||
function lookupMovedSessionFile(
|
||||
movedFiles: MovedSessionFiles,
|
||||
filePath: string,
|
||||
): string | undefined {
|
||||
const exact = movedFiles.exact.get(path.resolve(filePath));
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
const folded = sessionMovePathKey(filePath);
|
||||
if (movedFiles.ambiguousFolded.has(folded)) {
|
||||
return undefined;
|
||||
}
|
||||
return movedFiles.folded.get(folded);
|
||||
}
|
||||
|
||||
function resolveLegacySessionFileMovePlanPath(legacyDir: string): string {
|
||||
return path.join(legacyDir, LEGACY_SESSION_FILE_MOVE_PLAN_NAME);
|
||||
}
|
||||
|
||||
function isLegacySessionFileMovePlanName(name: string): boolean {
|
||||
return (
|
||||
name === LEGACY_SESSION_FILE_MOVE_PLAN_NAME ||
|
||||
name === `${LEGACY_SESSION_FILE_MOVE_PLAN_NAME}.tmp`
|
||||
);
|
||||
}
|
||||
|
||||
// Transcript moves happen before SQLite import so metadata can point at final
|
||||
// paths. Persist the plan first so a crash in that window can retry without
|
||||
// guessing conflict-renamed transcript names.
|
||||
function parseLegacySessionFileMovePlan(raw: string): LegacySessionFileMove[] | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
const moves = (parsed as { moves?: unknown }).moves;
|
||||
if (!Array.isArray(moves)) {
|
||||
return null;
|
||||
}
|
||||
const plan: LegacySessionFileMove[] = [];
|
||||
for (const move of moves) {
|
||||
if (!move || typeof move !== "object") {
|
||||
return null;
|
||||
}
|
||||
const rec = move as { from?: unknown; to?: unknown; name?: unknown };
|
||||
if (
|
||||
typeof rec.from !== "string" ||
|
||||
typeof rec.to !== "string" ||
|
||||
typeof rec.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
plan.push({
|
||||
from: rec.from,
|
||||
to: rec.to,
|
||||
name: rec.name,
|
||||
});
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function readLegacySessionFileMovePlan(params: {
|
||||
legacyDir: string;
|
||||
targetDir: string;
|
||||
}): LegacySessionFileMove[] | null {
|
||||
const legacyDir = path.resolve(params.legacyDir);
|
||||
const targetDir = path.resolve(params.targetDir);
|
||||
const planPath = resolveLegacySessionFileMovePlanPath(legacyDir);
|
||||
if (!fileExists(planPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const moves = parseLegacySessionFileMovePlan(fs.readFileSync(planPath, "utf-8"));
|
||||
if (!moves) {
|
||||
return null;
|
||||
}
|
||||
for (const move of moves) {
|
||||
if (
|
||||
!isWithinDir(legacyDir, move.from) ||
|
||||
!isWithinDir(targetDir, move.to) ||
|
||||
path.basename(move.from) !== move.name
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return moves;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLegacySessionFileMovePlan(params: {
|
||||
legacyDir: string;
|
||||
moves: LegacySessionFileMove[];
|
||||
}): void {
|
||||
if (params.moves.length === 0) {
|
||||
return;
|
||||
}
|
||||
const planPath = resolveLegacySessionFileMovePlanPath(params.legacyDir);
|
||||
const tempPath = `${planPath}.tmp`;
|
||||
fs.writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
moves: params.moves,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.renameSync(tempPath, planPath);
|
||||
}
|
||||
|
||||
function revalidateLegacySessionFileMovePlan(params: {
|
||||
moves: LegacySessionFileMove[];
|
||||
targetDir: string;
|
||||
now: () => number;
|
||||
}): { moves: LegacySessionFileMove[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const reservedPaths = new Set(
|
||||
safeReadDir(params.targetDir)
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => sessionMovePathKey(path.join(params.targetDir, entry.name))),
|
||||
);
|
||||
const moves: LegacySessionFileMove[] = [];
|
||||
for (const move of params.moves) {
|
||||
let to = move.to;
|
||||
const sourceExists = fileExists(move.from);
|
||||
const targetKey = sessionMovePathKey(to);
|
||||
if (sourceExists && reservedPaths.has(targetKey)) {
|
||||
to = nextLegacySessionConflictPath({
|
||||
targetDir: params.targetDir,
|
||||
name: move.name,
|
||||
now: params.now,
|
||||
reservedPaths,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
reservedPaths.add(sessionMovePathKey(to));
|
||||
moves.push(to === move.to ? move : { ...move, to });
|
||||
}
|
||||
return { moves, changed };
|
||||
}
|
||||
|
||||
function nextLegacySessionConflictPath(params: {
|
||||
targetDir: string;
|
||||
name: string;
|
||||
now: () => number;
|
||||
reservedPaths: Set<string>;
|
||||
}): string {
|
||||
const parsed = path.parse(params.name);
|
||||
const baseName = parsed.name || "session";
|
||||
const ext = parsed.ext || ".jsonl";
|
||||
const suffix = `.legacy-${params.now()}`;
|
||||
let index = 0;
|
||||
while (true) {
|
||||
const numbered = index === 0 ? "" : `-${index}`;
|
||||
const candidate = path.join(params.targetDir, `${baseName}${suffix}${numbered}${ext}`);
|
||||
if (!fileExists(candidate) && !params.reservedPaths.has(sessionMovePathKey(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionMovePathKey(filePath: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(path.resolve(filePath));
|
||||
}
|
||||
|
||||
function buildLegacySessionFileMovePlan(params: {
|
||||
legacyDir: string;
|
||||
targetDir: string;
|
||||
now: () => number;
|
||||
}): LegacySessionFileMove[] {
|
||||
const moves: LegacySessionFileMove[] = [];
|
||||
const entries = safeReadDir(params.legacyDir)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
entry.name !== "sessions.json" &&
|
||||
!isLegacySessionFileMovePlanName(entry.name),
|
||||
)
|
||||
.toSorted((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
||||
const existingTargetPaths = new Set(
|
||||
safeReadDir(params.targetDir)
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => sessionMovePathKey(path.join(params.targetDir, entry.name))),
|
||||
);
|
||||
const defaultTargetPaths = new Set(
|
||||
entries.map((entry) => sessionMovePathKey(path.join(params.targetDir, entry.name))),
|
||||
);
|
||||
const plannedTargetPaths = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.name === "sessions.json") {
|
||||
continue;
|
||||
}
|
||||
const from = path.join(params.legacyDir, entry.name);
|
||||
const defaultTo = path.join(params.targetDir, entry.name);
|
||||
const resolvedDefaultTo = sessionMovePathKey(defaultTo);
|
||||
const mustUseConflictName =
|
||||
fileExists(defaultTo) ||
|
||||
existingTargetPaths.has(resolvedDefaultTo) ||
|
||||
plannedTargetPaths.has(resolvedDefaultTo);
|
||||
const reservedPaths = new Set([
|
||||
...existingTargetPaths,
|
||||
...defaultTargetPaths,
|
||||
...plannedTargetPaths,
|
||||
]);
|
||||
if (!mustUseConflictName) {
|
||||
reservedPaths.delete(resolvedDefaultTo);
|
||||
}
|
||||
const to = mustUseConflictName
|
||||
? nextLegacySessionConflictPath({
|
||||
targetDir: params.targetDir,
|
||||
name: entry.name,
|
||||
now: params.now,
|
||||
reservedPaths,
|
||||
})
|
||||
: defaultTo;
|
||||
plannedTargetPaths.add(sessionMovePathKey(to));
|
||||
moves.push({
|
||||
from,
|
||||
to,
|
||||
name: entry.name,
|
||||
});
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
function canonicalizeSessionStore(params: {
|
||||
store: Record<string, SessionEntryLike>;
|
||||
agentId: string;
|
||||
@@ -2687,8 +3024,10 @@ export async function detectLegacyStateMigrations(params: {
|
||||
const sessionsTargetDir = path.join(stateDir, "agents", targetAgentId, "sessions");
|
||||
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
|
||||
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
|
||||
const legacySessionMovePlanPath = resolveLegacySessionFileMovePlanPath(sessionsLegacyDir);
|
||||
const hasLegacySessions =
|
||||
fileExists(sessionsLegacyStorePath) ||
|
||||
fileExists(legacySessionMovePlanPath) ||
|
||||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
||||
|
||||
const targetSessionParsed = fileExists(sessionsTargetStorePath)
|
||||
@@ -2909,28 +3248,6 @@ async function migrateLegacySessions(
|
||||
scope: detected.targetScope,
|
||||
});
|
||||
|
||||
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,
|
||||
mainKey: detected.targetMainKey,
|
||||
});
|
||||
let migratedDirectChatKey: string | undefined;
|
||||
if (!merged[mainKey]) {
|
||||
const latest = pickLatestLegacyDirectEntry(legacyStore);
|
||||
if (latest?.sessionId) {
|
||||
merged[mainKey] = latest;
|
||||
migratedDirectChatKey = mainKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!legacyParsed.ok) {
|
||||
warnings.push(
|
||||
`Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`,
|
||||
@@ -2958,20 +3275,121 @@ async function migrateLegacySessions(
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetReadable) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const persistedSessionFileMovePlans = readLegacySessionFileMovePlan({
|
||||
legacyDir: detected.sessions.legacyDir,
|
||||
targetDir: detected.sessions.targetDir,
|
||||
});
|
||||
const revalidatedSessionFileMovePlans = persistedSessionFileMovePlans
|
||||
? revalidateLegacySessionFileMovePlan({
|
||||
moves: persistedSessionFileMovePlans,
|
||||
targetDir: detected.sessions.targetDir,
|
||||
now,
|
||||
})
|
||||
: null;
|
||||
const movedSessionFilePlans =
|
||||
revalidatedSessionFileMovePlans?.moves ??
|
||||
buildLegacySessionFileMovePlan({
|
||||
legacyDir: detected.sessions.legacyDir,
|
||||
targetDir: detected.sessions.targetDir,
|
||||
now,
|
||||
});
|
||||
if (!persistedSessionFileMovePlans || revalidatedSessionFileMovePlans?.changed) {
|
||||
writeLegacySessionFileMovePlan({
|
||||
legacyDir: detected.sessions.legacyDir,
|
||||
moves: movedSessionFilePlans,
|
||||
});
|
||||
}
|
||||
const movedSessionFiles = buildMovedSessionFiles(movedSessionFilePlans);
|
||||
const completedMovedSessionFilePlans: LegacySessionFileMove[] = [];
|
||||
for (const move of movedSessionFilePlans) {
|
||||
try {
|
||||
if (fileExists(move.from)) {
|
||||
fs.renameSync(move.from, move.to);
|
||||
} else if (!fileExists(move.to)) {
|
||||
warnings.push(`Skipped missing legacy transcript ${move.from}`);
|
||||
continue;
|
||||
}
|
||||
recordMovedSessionFile({
|
||||
movedFiles: movedSessionFiles,
|
||||
move,
|
||||
});
|
||||
completedMovedSessionFilePlans.push(move);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed moving ${move.from}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rewrittenLegacyStore = rewriteLegacySessionFilePaths({
|
||||
store: canonicalizedLegacy.store,
|
||||
legacyDir: detected.sessions.legacyDir,
|
||||
movedFiles: movedSessionFiles,
|
||||
});
|
||||
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
|
||||
for (const [key, entry] of Object.entries(rewrittenLegacyStore)) {
|
||||
merged[key] = mergeSessionEntry({
|
||||
existing: merged[key],
|
||||
incoming: entry,
|
||||
preferIncomingOnTie: false,
|
||||
});
|
||||
}
|
||||
|
||||
const mainKey = buildAgentMainSessionKey({
|
||||
agentId: detected.targetAgentId,
|
||||
mainKey: detected.targetMainKey,
|
||||
});
|
||||
let migratedDirectChatKey: string | undefined;
|
||||
if (!merged[mainKey]) {
|
||||
const latest = pickLatestLegacyDirectEntry(legacyStore);
|
||||
if (latest?.sessionId) {
|
||||
const latestStore = rewriteLegacySessionFilePaths({
|
||||
store: { latest },
|
||||
legacyDir: detected.sessions.legacyDir,
|
||||
movedFiles: movedSessionFiles,
|
||||
});
|
||||
merged[mainKey] = latestStore.latest ?? latest;
|
||||
migratedDirectChatKey = mainKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
targetReadable &&
|
||||
(legacyParsed.ok || targetParsed.ok) &&
|
||||
(targetExists ||
|
||||
fileExists(detected.sessions.legacyStorePath) ||
|
||||
Object.keys(legacyStore).length > 0 ||
|
||||
Object.keys(targetStore).length > 0)
|
||||
) {
|
||||
const { imported, acpMigrated } = importNormalizedSessionsIntoSqlite({
|
||||
storePath: detected.sessions.targetStorePath,
|
||||
store: merged,
|
||||
stateDir: detected.stateDir,
|
||||
now,
|
||||
});
|
||||
let imported: number;
|
||||
let acpMigrated: number;
|
||||
try {
|
||||
const result = importNormalizedSessionsIntoSqlite({
|
||||
storePath: detected.sessions.targetStorePath,
|
||||
store: merged,
|
||||
stateDir: detected.stateDir,
|
||||
now,
|
||||
});
|
||||
imported = result.imported;
|
||||
acpMigrated = result.acpMigrated;
|
||||
} catch (err) {
|
||||
const rollbackFailures: string[] = [];
|
||||
for (const move of completedMovedSessionFilePlans.toReversed()) {
|
||||
try {
|
||||
fs.renameSync(move.to, move.from);
|
||||
} catch (rollbackErr) {
|
||||
rollbackFailures.push(`${move.to}: ${String(rollbackErr)}`);
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw new Error(
|
||||
`Failed importing session metadata: ${String(err)}; additionally failed rolling back moved transcript(s): ${rollbackFailures.join("; ")}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (migratedDirectChatKey) {
|
||||
changes.push(`Migrated latest direct-chat session → ${migratedDirectChatKey}`);
|
||||
}
|
||||
@@ -2995,29 +3413,13 @@ async function migrateLegacySessions(
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetReadable) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const entries = safeReadDir(detected.sessions.legacyDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name === "sessions.json") {
|
||||
continue;
|
||||
}
|
||||
const from = path.join(detected.sessions.legacyDir, entry.name);
|
||||
const to = path.join(detected.sessions.targetDir, entry.name);
|
||||
if (fileExists(to)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(from, to);
|
||||
changes.push(`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed moving ${from}: ${String(err)}`);
|
||||
}
|
||||
for (const move of completedMovedSessionFilePlans) {
|
||||
const movedName = path.basename(move.to);
|
||||
changes.push(
|
||||
movedName === move.name
|
||||
? `Moved ${move.name} → agents/${detected.targetAgentId}/sessions`
|
||||
: `Moved ${move.name} → agents/${detected.targetAgentId}/sessions/${movedName}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyParsed.ok && targetReadable) {
|
||||
@@ -3030,6 +3432,17 @@ async function migrateLegacySessions(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const movePlanPath = resolveLegacySessionFileMovePlanPath(detected.sessions.legacyDir);
|
||||
if (fileExists(movePlanPath)) {
|
||||
fs.rmSync(movePlanPath, { force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Migrated legacy sessions, but failed removing ${LEGACY_SESSION_FILE_MOVE_PLAN_NAME}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
removeDirIfEmpty(detected.sessions.legacyDir);
|
||||
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) => e.isFile());
|
||||
if (legacyLeft.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user