From 2fa889d8b37c86e94b362e49d14eb6eaac4fb58c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 22:53:39 -0700 Subject: [PATCH] fix(state): preserve lazy placement column ownership (#125634) --- .../plugin-state-store.schema.test.ts | 125 ++++++++++++++++++ .../openclaw-state-db-additive-columns.ts | 3 +- src/state/openclaw-state-db.test.ts | 14 +- 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/plugin-state/plugin-state-store.schema.test.ts diff --git a/src/plugin-state/plugin-state-store.schema.test.ts b/src/plugin-state/plugin-state-store.schema.test.ts new file mode 100644 index 000000000000..02bf080834e1 --- /dev/null +++ b/src/plugin-state/plugin-state-store.schema.test.ts @@ -0,0 +1,125 @@ +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; +import { OPENCLAW_STATE_SCHEMA_VERSION } from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import { + createPluginStateKeyedStore, + resetPluginStateStoreForTests, +} from "./plugin-state-store.js"; + +describe("plugin state schema compatibility", () => { + it("cold-opens when an existing move table lacks its first-use column", async () => { + await withOpenClawTestState( + { label: "plugin-state-placement-move-column", applyEnv: false }, + async (state) => { + try { + const first = createPluginStateKeyedStore<{ owner: string }>("discord", { + namespace: "same-version-placement-move", + maxEntries: 10, + env: state.env, + }); + await first.register("first", { owner: "discord" }); + resetPluginStateStoreForTests(); + + const databasePath = resolveOpenClawStateSqlitePath(state.env); + const previousDatabase = new DatabaseSync(databasePath); + let versionBefore: unknown; + let metadataBefore: unknown; + try { + versionBefore = previousDatabase.prepare("PRAGMA user_version").get(); + metadataBefore = previousDatabase + .prepare("SELECT * FROM schema_meta WHERE meta_key = 'primary'") + .get(); + expect(versionBefore).toEqual({ user_version: OPENCLAW_STATE_SCHEMA_VERSION }); + expect( + previousDatabase + .prepare( + "SELECT plugin_id, namespace, entry_key, value_json FROM plugin_state_entries", + ) + .all(), + ).toEqual([ + { + plugin_id: "discord", + namespace: "same-version-placement-move", + entry_key: "first", + value_json: JSON.stringify({ owner: "discord" }), + }, + ]); + expect( + previousDatabase + .prepare("SELECT COUNT(*) AS count FROM worker_session_placement_moves") + .get(), + ).toEqual({ count: 0 }); + const columnsBefore = previousDatabase + .prepare("PRAGMA table_info(worker_session_placement_moves)") + .all() + .map((column) => (column as { name: string }).name); + expect(columnsBefore).toContain("target_machine_class"); + previousDatabase.exec( + "ALTER TABLE worker_session_placement_moves DROP COLUMN target_machine_class;", + ); + expect( + previousDatabase + .prepare("PRAGMA table_info(worker_session_placement_moves)") + .all() + .map((column) => (column as { name: string }).name), + ).toEqual(columnsBefore.filter((column) => column !== "target_machine_class")); + } finally { + previousDatabase.close(); + } + + const second = createPluginStateKeyedStore<{ owner: string }>("telegram", { + namespace: "same-version-placement-move", + maxEntries: 10, + env: state.env, + }); + await second.register("second", { owner: "telegram" }); + resetPluginStateStoreForTests(); + + const reopenedDatabase = new DatabaseSync(databasePath); + try { + expect( + reopenedDatabase + .prepare( + `SELECT plugin_id, namespace, entry_key, value_json + FROM plugin_state_entries + ORDER BY plugin_id`, + ) + .all(), + ).toEqual([ + { + plugin_id: "discord", + namespace: "same-version-placement-move", + entry_key: "first", + value_json: JSON.stringify({ owner: "discord" }), + }, + { + plugin_id: "telegram", + namespace: "same-version-placement-move", + entry_key: "second", + value_json: JSON.stringify({ owner: "telegram" }), + }, + ]); + expect( + reopenedDatabase + .prepare("PRAGMA table_info(worker_session_placement_moves)") + .all() + .map((column) => (column as { name: string }).name), + ).not.toContain("target_machine_class"); + expect(reopenedDatabase.prepare("PRAGMA user_version").get()).toEqual(versionBefore); + expect( + reopenedDatabase + .prepare("SELECT * FROM schema_meta WHERE meta_key = 'primary'") + .get(), + ).toEqual(metadataBefore); + } finally { + reopenedDatabase.close(); + } + } finally { + resetPluginStateStoreForTests(); + } + }, + ); + }); +}); diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts index ad0524aa2ae8..a091e021c94f 100644 --- a/src/state/openclaw-state-db-additive-columns.ts +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -42,12 +42,13 @@ function isFirstUseAdditiveStateColumn({ }: LazyAdditiveStateColumnDefinition): boolean { return ( (tableName === "device_bootstrap_tokens" && columnName === "setup_id") || + (tableName === "worker_session_placement_moves" && columnName === "target_machine_class") || (tableName === "session_groups" && (columnName === "cwd" || columnName === "worktree")) ); } // Most same-version columns repair during a writable shared-state open. These -// feature-owned columns stay absent until setup or group defaults first uses them. +// feature-owned columns stay absent until their feature first uses them. export const CLAW_STARTUP_ADDITIVE_STATE_COLUMN_DEFINITIONS = CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter( (definition) => !isFirstUseAdditiveStateColumn(definition), diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 6689a262491c..c1afec88f3f2 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -4444,7 +4444,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ]); }); - it("repairs target machine class in a pre-column placement move table", () => { + it("keeps placement-owned target machine class absent during generic repair and open", () => { const stateDir = createTempStateDir(); const databasePath = materializeCurrentStateDatabase(stateDir); const previousSchema = OPENCLAW_STATE_SCHEMA_SQL.replace( @@ -4468,12 +4468,22 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; expect(repairOpenClawStateDatabaseSchemaIfNeeded(options).warnings).toEqual([]); + const repairedDb = new DatabaseSync(databasePath, { readOnly: true }); + try { + const repairedColumns = repairedDb + .prepare("PRAGMA table_info(worker_session_placement_moves)") + .all() as Array<{ name?: string }>; + expect(repairedColumns.map((column) => column.name)).not.toContain("target_machine_class"); + } finally { + repairedDb.close(); + } + const reopened = openOpenClawStateDatabase(options); const columns = reopened.db .prepare("PRAGMA table_info(worker_session_placement_moves)") .all() as Array<{ name?: string }>; - expect(columns.map((column) => column.name)).toContain("target_machine_class"); + expect(columns.map((column) => column.name)).not.toContain("target_machine_class"); }); it("adds staged worker-result refs during the v5 state migration", () => {