fix: migrate active memory toggles

This commit is contained in:
Peter Steinberger
2026-05-16 02:02:42 +01:00
parent 641ff8ac09
commit 7fd68987cb
2 changed files with 214 additions and 0 deletions

View File

@@ -458,6 +458,79 @@ describe("state migrations", () => {
await expectMissingPath(legacyPluginStatePath);
});
it("imports legacy Active Memory session toggles into unified plugin state", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const cfg = createConfig();
const legacyTogglePath = path.join(
stateDir,
"plugins",
"active-memory",
"session-toggles.json",
);
await fs.mkdir(path.dirname(legacyTogglePath), { recursive: true });
await fs.writeFile(
legacyTogglePath,
`${JSON.stringify(
{
sessions: {
"agent:main:disabled": { disabled: true, updatedAt: 111 },
"agent:main:enabled": { disabled: false, updatedAt: 222 },
" ": { disabled: true, updatedAt: 333 },
},
},
null,
2,
)}\n`,
"utf8",
);
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
});
expect(detected.preview).toEqual([
`- Active Memory session toggles: ${legacyTogglePath} → SQLite`,
]);
const result = await runLegacyStateMigrations({
detected,
now: () => 1234,
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toEqual([
"Imported 1 Active Memory session toggle(s) into SQLite plugin state",
]);
const stateDatabase = openOpenClawStateDatabase({ env });
const db = getNodeSqliteKysely<PluginStateTestDatabase>(stateDatabase.db);
const rows = executeSqliteQuerySync(
stateDatabase.db,
db
.selectFrom("plugin_state_entries")
.select(["plugin_id", "namespace", "entry_key", "value_json", "created_at", "expires_at"])
.orderBy("plugin_id", "asc")
.orderBy("namespace", "asc")
.orderBy("entry_key", "asc"),
).rows;
expect(rows).toEqual([
{
plugin_id: "active-memory",
namespace: "session-toggles",
entry_key: "agent:main:disabled",
value_json: '{"version":1,"disabled":true,"updatedAt":111}',
created_at: 111,
expires_at: null,
},
]);
await expectMissingPath(legacyTogglePath);
});
it("migrates legacy sessions for every configured agent", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");

View File

@@ -128,6 +128,12 @@ type MigrationSourceReport = {
recordCount?: number;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function parseJsonlEvents(filePath: string): unknown[] {
const raw = fs.readFileSync(filePath, "utf-8");
const events: unknown[] = [];
@@ -1373,6 +1379,33 @@ function collectCoreLegacyStateMigrationPlans(params: {
},
});
}
const activeMemorySessionTogglesPath = resolveLegacyActiveMemorySessionTogglesPath(
params.stateDir,
);
if (fileExists(activeMemorySessionTogglesPath)) {
plans.push({
kind: "custom",
label: "Active Memory session toggles",
sourcePath: activeMemorySessionTogglesPath,
targetTable: "plugin_state_entries",
recordCount: countLegacyActiveMemorySessionToggleRecords(activeMemorySessionTogglesPath),
apply: () => {
const result = importLegacyActiveMemorySessionTogglesToSqlite(
activeMemorySessionTogglesPath,
params.env,
);
return {
changes:
result.imported > 0
? [
`Imported ${result.imported} Active Memory session toggle(s) into SQLite plugin state`,
]
: [],
warnings: result.warnings,
};
},
});
}
const acpxGatewayInstancePath = path.join(params.stateDir, "gateway-instance-id");
if (fileExists(acpxGatewayInstancePath)) {
plans.push({
@@ -1891,11 +1924,23 @@ const CLAWHUB_SKILL_STATE_OWNER_ID = "core:clawhub-skills";
const CLAWHUB_SKILL_STATE_NAMESPACE = "skill-installs";
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
const LEGACY_PLUGIN_STATE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const;
const ACTIVE_MEMORY_PLUGIN_STATE_OWNER_ID = "active-memory";
const ACTIVE_MEMORY_SESSION_TOGGLES_NAMESPACE = "session-toggles";
const LEGACY_ACTIVE_MEMORY_SESSION_TOGGLES_FILE = "session-toggles.json";
function resolveLegacyPluginStateSqlitePath(stateDir: string): string {
return path.join(stateDir, "plugin-state", "state.sqlite");
}
function resolveLegacyActiveMemorySessionTogglesPath(stateDir: string): string {
return path.join(
stateDir,
"plugins",
ACTIVE_MEMORY_PLUGIN_STATE_OWNER_ID,
LEGACY_ACTIVE_MEMORY_SESSION_TOGGLES_FILE,
);
}
function readLegacyPluginStateSqliteRows(sourcePath: string): {
rows: PluginStateMigrationRow[];
warnings: string[];
@@ -1974,6 +2019,102 @@ function importLegacyPluginStateSqliteToUnified(
return { imported: result.rows.length, warnings: [] };
}
function readLegacyActiveMemorySessionToggleRows(sourcePath: string): {
rows: PluginStateMigrationRow[];
warnings: string[];
} {
let fallbackUpdatedAt = 0;
try {
fallbackUpdatedAt = Math.trunc(fs.statSync(sourcePath).mtimeMs);
} catch {
// keep deterministic zero fallback when stat fails
}
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as unknown;
} catch (error) {
return {
rows: [],
warnings: [`Failed reading legacy Active Memory toggles (${sourcePath}): ${String(error)}`],
};
}
const sessions = asRecord(parsed)?.sessions;
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
return { rows: [], warnings: [] };
}
const rows: PluginStateMigrationRow[] = [];
for (const [rawSessionKey, rawValue] of Object.entries(sessions)) {
const sessionKey = rawSessionKey.trim();
const value = asRecord(rawValue);
if (!sessionKey || value?.disabled !== true) {
continue;
}
const updatedAt =
typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)
? Math.trunc(value.updatedAt)
: fallbackUpdatedAt;
rows.push({
plugin_id: ACTIVE_MEMORY_PLUGIN_STATE_OWNER_ID,
namespace: ACTIVE_MEMORY_SESSION_TOGGLES_NAMESPACE,
entry_key: sessionKey,
value_json: JSON.stringify({
version: 1,
disabled: true,
updatedAt,
}),
created_at: updatedAt,
expires_at: null,
});
}
return { rows, warnings: [] };
}
function countLegacyActiveMemorySessionToggleRecords(sourcePath: string): number | undefined {
const result = readLegacyActiveMemorySessionToggleRows(sourcePath);
return result.warnings.length === 0 ? result.rows.length : undefined;
}
function importLegacyActiveMemorySessionTogglesToSqlite(
sourcePath: string,
env: NodeJS.ProcessEnv,
): { imported: number; warnings: string[] } {
const result = readLegacyActiveMemorySessionToggleRows(sourcePath);
if (result.warnings.length > 0) {
return { imported: 0, warnings: result.warnings };
}
if (result.rows.length > 0) {
runOpenClawStateWriteTransaction(
(stateDatabase) => {
const db = getNodeSqliteKysely<PluginStateMigrationDatabase>(stateDatabase.db);
for (const row of result.rows) {
executeSqliteQuerySync(
stateDatabase.db,
db
.insertInto("plugin_state_entries")
.values(row)
.onConflict((conflict) =>
conflict.columns(["plugin_id", "namespace", "entry_key"]).doUpdateSet({
value_json: (eb) => eb.ref("excluded.value_json"),
created_at: (eb) => eb.ref("excluded.created_at"),
expires_at: (eb) => eb.ref("excluded.expires_at"),
}),
),
);
}
},
{ env },
);
}
fs.rmSync(sourcePath, { force: true });
removeDirIfEmpty(path.dirname(sourcePath));
return { imported: result.rows.length, warnings: [] };
}
function collectLegacyMigrationSources(detected: LegacyStateDetection): MigrationSourceReport[] {
const sources: MigrationSourceReport[] = [];
const add = (source: MigrationSourceReport | null | undefined) => {