fix(state): preserve lazy placement column ownership (#125634)

This commit is contained in:
Peter Steinberger
2026-08-17 22:53:39 -07:00
committed by GitHub
parent ac7dbe4f83
commit 2fa889d8b3
3 changed files with 139 additions and 3 deletions
@@ -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();
}
},
);
});
});
@@ -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),
+12 -2
View File
@@ -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", () => {