From d71cc9c360eb5722875bc1a469b486e9143b3e4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 23:31:37 +0100 Subject: [PATCH] fix: preserve sqlite session compat rows --- src/plugin-sdk/session-store-runtime.test.ts | 104 +++++++++++++++++++ src/plugin-sdk/session-store-runtime.ts | 26 +++-- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/src/plugin-sdk/session-store-runtime.test.ts b/src/plugin-sdk/session-store-runtime.test.ts index ce3411e71d6..0ed4efdb3c4 100644 --- a/src/plugin-sdk/session-store-runtime.test.ts +++ b/src/plugin-sdk/session-store-runtime.test.ts @@ -3,12 +3,23 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { + getSessionEntry, loadSessionStore, readSessionUpdatedAt, + saveSessionStore, + updateSessionStore, upsertSessionEntry, } from "./session-store-runtime.js"; describe("session-store-runtime compatibility", () => { + function canonicalStorePath(stateDir: string): string { + return path.join(stateDir, "agents", "main", "sessions", "sessions.json"); + } + + function testEnv(stateDir: string): NodeJS.ProcessEnv { + return { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + } + it("rejects custom store paths instead of falling back to the default agent", async () => { await withOpenClawTestState( { @@ -42,4 +53,97 @@ describe("session-store-runtime compatibility", () => { }, ); }); + + it("does not delete rows created after a compatibility snapshot was loaded", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-session-store-compat-", + scenario: "minimal", + }, + async (state) => { + const env = testEnv(state.stateDir); + const storePath = canonicalStorePath(state.stateDir); + const staleSnapshot = loadSessionStore(storePath); + + upsertSessionEntry({ + agentId: "main", + env, + sessionKey: "agent:main:concurrent", + entry: { + sessionId: "concurrent-session", + updatedAt: 200, + sessionStartedAt: 200, + }, + }); + + staleSnapshot["agent:main:legacy"] = { + sessionId: "legacy-session", + updatedAt: 100, + sessionStartedAt: 100, + }; + await saveSessionStore(storePath, staleSnapshot); + + expect( + getSessionEntry({ agentId: "main", env, sessionKey: "agent:main:concurrent" })?.sessionId, + ).toBe("concurrent-session"); + expect( + getSessionEntry({ agentId: "main", env, sessionKey: "agent:main:legacy" })?.sessionId, + ).toBe("legacy-session"); + }, + ); + }); + + it("keeps updateSessionStore deletes scoped to rows visible before mutation", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-session-store-compat-", + scenario: "minimal", + }, + async (state) => { + const env = testEnv(state.stateDir); + const storePath = canonicalStorePath(state.stateDir); + upsertSessionEntry({ + agentId: "main", + env, + sessionKey: "agent:main:old", + entry: { + sessionId: "old-session", + updatedAt: 100, + sessionStartedAt: 100, + }, + }); + + await updateSessionStore(storePath, (store) => { + delete store["agent:main:old"]; + upsertSessionEntry({ + agentId: "main", + env, + sessionKey: "agent:main:concurrent", + entry: { + sessionId: "concurrent-session", + updatedAt: 200, + sessionStartedAt: 200, + }, + }); + store["agent:main:new"] = { + sessionId: "new-session", + updatedAt: 300, + sessionStartedAt: 300, + }; + }); + + expect( + getSessionEntry({ agentId: "main", env, sessionKey: "agent:main:old" }), + ).toBeUndefined(); + expect( + getSessionEntry({ agentId: "main", env, sessionKey: "agent:main:concurrent" })?.sessionId, + ).toBe("concurrent-session"); + expect( + getSessionEntry({ agentId: "main", env, sessionKey: "agent:main:new" })?.sessionId, + ).toBe("new-session"); + }, + ); + }); }); diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 0c7d962f57a..6f5a6c6f1d9 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -202,10 +202,19 @@ export async function saveSessionStore( ): Promise { normalizeSessionEntries(store); const options = resolveSessionRowOptionsFromStorePath(storePath); - const existing = loadSqliteSessionEntries(options); - for (const sessionKey of Object.keys(existing)) { - if (!Object.prototype.hasOwnProperty.call(store, sessionKey)) { - deleteSessionEntry({ ...options, sessionKey }); + await saveSessionStoreRows(options, store); +} + +async function saveSessionStoreRows( + options: SessionRowOptions, + store: Record, + deleteScope?: ReadonlySet, +): Promise { + if (deleteScope) { + for (const sessionKey of deleteScope) { + if (!Object.prototype.hasOwnProperty.call(store, sessionKey)) { + deleteSessionEntry({ ...options, sessionKey }); + } } } for (const [sessionKey, entry] of Object.entries(store)) { @@ -216,11 +225,14 @@ export async function saveSessionStore( export async function updateSessionStore( storePath: string, mutator: (store: Record) => Promise | T, - opts?: SaveSessionStoreOptions, + _opts?: SaveSessionStoreOptions, ): Promise { - const store = loadSessionStore(storePath); + const options = resolveSessionRowOptionsFromStorePath(storePath); + const store = loadSqliteSessionEntries(options); + const deleteScope = new Set(Object.keys(store)); const result = await mutator(store); - await saveSessionStore(storePath, store, opts); + normalizeSessionEntries(store); + await saveSessionStoreRows(options, store, deleteScope); return result; }