mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Claude Fable 5
parent
29b4b74a5a
commit
25a8b33ff5
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user