fix(state): make managed-image additive migration portable across SQLite versions (#111167)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason (Json)
2026-07-19 00:10:48 -06:00
committed by GitHub
co-authored by Claude Fable 5
parent 29b4b74a5a
commit 25a8b33ff5
2 changed files with 211 additions and 41 deletions
+59 -2
View File
@@ -1,4 +1,6 @@
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
backfillAcpReplayEstimatedBytes,
backfillCronJobsFromJobJson,
@@ -11,6 +13,53 @@ import {
} from "./openclaw-state-db-legacy-backfills.js";
import { ensureColumn } from "./openclaw-state-db-schema-helpers.js";
function resolveLegacyManagedImageRoot(recordJson: unknown): string | null {
if (typeof recordJson !== "string") {
return null;
}
let record: unknown;
try {
record = JSON.parse(recordJson) as unknown;
} catch {
return null;
}
if (!isRecord(record) || !isRecord(record.original)) {
return null;
}
const mediaRoot = record.original.mediaRoot;
if (typeof mediaRoot === "string" && mediaRoot.trim()) {
return path.resolve(mediaRoot);
}
const originalPath = record.original.path;
if (typeof originalPath !== "string" || !originalPath.trim()) {
return null;
}
const resolvedOriginalPath = path.resolve(originalPath);
return path.dirname(path.dirname(path.dirname(resolvedOriginalPath)));
}
function backfillLegacyManagedImageRoots(db: DatabaseSync): void {
const rows = db
.prepare("SELECT attachment_id, record_json FROM managed_outgoing_image_records")
.all() as Array<{ attachment_id: string; record_json: unknown }>;
const updateRoot = db.prepare(
"UPDATE managed_outgoing_image_records SET original_media_root = ? WHERE attachment_id = ?",
);
const deleteRecord = db.prepare(
"DELETE FROM managed_outgoing_image_records WHERE attachment_id = ?",
);
for (const row of rows) {
const mediaRoot = resolveLegacyManagedImageRoot(row.record_json);
if (mediaRoot) {
updateRoot.run(mediaRoot, row.attachment_id);
} else {
// This table had no shipped writer. Discard malformed unexpected rows
// instead of retaining unusable empty roots or wedging every database open.
deleteRecord.run(row.attachment_id);
}
}
}
export function ensureAdditiveStateColumns(db: DatabaseSync): void {
const addedDiagnosticEventSequence = ensureColumn(
db,
@@ -190,8 +239,16 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "commitments", "snoozed_until_ms INTEGER");
ensureColumn(db, "commitments", "expired_at_ms INTEGER");
// The shipped JSON runtime predeclared this table but never populated it.
// Add required typed columns before Doctor or runtime can insert canonical rows.
ensureColumn(db, "managed_outgoing_image_records", "original_media_root TEXT NOT NULL");
// The transitional default makes ADD COLUMN portable; schema-v2 tables are
// rebuilt from canonical STRICT SQL immediately afterward, removing it.
const addedOriginalMediaRoot = ensureColumn(
db,
"managed_outgoing_image_records",
"original_media_root TEXT NOT NULL DEFAULT ''",
);
if (addedOriginalMediaRoot) {
backfillLegacyManagedImageRoots(db);
}
ensureColumn(db, "managed_outgoing_image_records", "agent_id TEXT");
ensureColumn(
db,
+152 -39
View File
@@ -48,6 +48,90 @@ function createTempStateDir(): string {
return makeTempDir(stateDbTempDirs, "openclaw-state-db-");
}
function replaceManagedImageRecordsWithLegacyTable(
database: DatabaseSync,
options: { withRow: boolean },
): void {
database.exec(`
DROP TABLE managed_outgoing_image_records;
CREATE TABLE managed_outgoing_image_records (
attachment_id TEXT NOT NULL PRIMARY KEY,
session_key TEXT NOT NULL,
message_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
retention_class TEXT,
alt TEXT NOT NULL,
original_media_id TEXT NOT NULL,
original_media_subdir TEXT NOT NULL,
original_content_type TEXT NOT NULL,
original_width INTEGER,
original_height INTEGER,
original_size_bytes INTEGER,
original_filename TEXT,
record_json TEXT NOT NULL
);
CREATE INDEX idx_managed_outgoing_images_session
ON managed_outgoing_image_records(session_key, created_at DESC, attachment_id);
CREATE INDEX idx_managed_outgoing_images_message
ON managed_outgoing_image_records(session_key, message_id, attachment_id)
WHERE message_id IS NOT NULL;
PRAGMA user_version = 2;
UPDATE schema_meta SET schema_version = 2 WHERE meta_key = 'primary';
`);
if (!options.withRow) {
return;
}
const record = {
attachmentId: "legacy-attachment",
sessionKey: "agent:main:legacy",
messageId: "legacy-message",
createdAt: "2026-07-17T00:00:00.000Z",
alt: "legacy image",
original: {
path: "/legacy/media/outgoing/originals/legacy-media",
contentType: "image/png",
width: 640,
height: 480,
sizeBytes: 1234,
filename: "legacy.png",
},
};
database
.prepare(
`INSERT INTO managed_outgoing_image_records (
attachment_id,
session_key,
message_id,
created_at,
alt,
original_media_id,
original_media_subdir,
original_content_type,
original_width,
original_height,
original_size_bytes,
original_filename,
record_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
record.attachmentId,
record.sessionKey,
record.messageId,
record.createdAt,
record.alt,
"legacy-media",
"outgoing/originals",
record.original.contentType,
record.original.width,
record.original.height,
record.original.sizeBytes,
record.original.filename,
JSON.stringify(record),
);
}
const LEGACY_SESSION_WATCH_SCHEMA_VERSION = 3;
const LEGACY_AMBIENT_WATCH_PREFIX = "ambient-group-watch:";
@@ -2404,47 +2488,76 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
expect(result.warnings[0]).not.toContain("run openclaw doctor --fix");
});
it("adds managed-image typed columns before creating canonical indexes", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = openOpenClawStateDatabase(options).path;
closeOpenClawStateDatabaseForTest();
it.each([
{ migrationPath: "runtime open", withRow: false },
{ migrationPath: "doctor repair", withRow: true },
])(
"restores the true legacy managed-image table through $migrationPath",
({ migrationPath, withRow }) => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = openOpenClawStateDatabase(options).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
legacyDb.exec(`
DROP INDEX idx_managed_outgoing_images_session;
DROP INDEX idx_managed_outgoing_images_message;
DROP INDEX idx_managed_outgoing_images_agent_session;
DROP INDEX idx_managed_outgoing_images_agent_message;
ALTER TABLE managed_outgoing_image_records DROP COLUMN original_media_root;
ALTER TABLE managed_outgoing_image_records DROP COLUMN agent_id;
ALTER TABLE managed_outgoing_image_records DROP COLUMN cleanup_pending;
`);
legacyDb.close();
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
replaceManagedImageRecordsWithLegacyTable(legacyDb, { withRow });
legacyDb.close();
const reopened = openOpenClawStateDatabase(options);
const columns = reopened.db
.prepare("PRAGMA table_info(managed_outgoing_image_records)")
.all() as Array<{ name?: unknown; notnull?: unknown }>;
expect(columns).toContainEqual(
expect.objectContaining({ name: "original_media_root", notnull: 1 }),
);
expect(columns).toContainEqual(expect.objectContaining({ name: "agent_id" }));
expect(columns).toContainEqual(expect.objectContaining({ name: "cleanup_pending" }));
assertOpenClawStateDatabaseForMaintenance(reopened.db, { pathname: reopened.path });
const indexes = reopened.db
.prepare("PRAGMA index_list(managed_outgoing_image_records)")
.all() as Array<{ name?: unknown }>;
expect(indexes).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "idx_managed_outgoing_images_session" }),
expect.objectContaining({ name: "idx_managed_outgoing_images_message" }),
expect.objectContaining({ name: "idx_managed_outgoing_images_agent_session" }),
expect.objectContaining({ name: "idx_managed_outgoing_images_agent_message" }),
]),
);
});
if (migrationPath === "doctor repair") {
expect(repairOpenClawStateDatabaseSchema(options).warnings).toEqual([]);
}
const reopened = openOpenClawStateDatabase(options);
const columns = reopened.db
.prepare("PRAGMA table_info(managed_outgoing_image_records)")
.all() as Array<{ dflt_value?: unknown; name?: unknown; notnull?: unknown }>;
expect(columns).toContainEqual(
expect.objectContaining({ dflt_value: null, name: "original_media_root", notnull: 1 }),
);
expect(columns).toContainEqual(expect.objectContaining({ name: "agent_id" }));
expect(columns).toContainEqual(expect.objectContaining({ name: "cleanup_pending" }));
assertOpenClawStateDatabaseForMaintenance(reopened.db, { pathname: reopened.path });
const tableSql = reopened.db
.prepare(
"SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'managed_outgoing_image_records'",
)
.get() as { sql: string };
expect(
tableSql.sql
.split("\n")
.find((line) => line.includes("original_media_root"))
?.trim()
.replace(/,$/u, ""),
).toBe("original_media_root TEXT NOT NULL");
expect(tableSql.sql).toMatch(/\) STRICT$/u);
const indexes = reopened.db
.prepare("PRAGMA index_list(managed_outgoing_image_records)")
.all() as Array<{ name?: unknown }>;
expect(indexes).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "idx_managed_outgoing_images_session" }),
expect.objectContaining({ name: "idx_managed_outgoing_images_message" }),
expect.objectContaining({ name: "idx_managed_outgoing_images_agent_session" }),
expect.objectContaining({ name: "idx_managed_outgoing_images_agent_message" }),
]),
);
if (withRow) {
expect(
reopened.db
.prepare(
`SELECT attachment_id, original_media_root, agent_id, cleanup_pending
FROM managed_outgoing_image_records`,
)
.get(),
).toEqual({
agent_id: null,
attachment_id: "legacy-attachment",
cleanup_pending: 0,
original_media_root: "/legacy/media",
});
}
},
);
it("backfills diagnostic event sequences in legacy creation order", () => {
const stateDir = createTempStateDir();