import CryptoKit import Foundation import GRDB import OSLog private let databaseLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawClientDatabases") private struct GatewayCacheFormatMismatch: Error {} private enum GatewayRemovalPhase: Int { case finalized = 0 case staged = 1 case committing = 2 case scrubbing = 3 } private struct RegisteredGatewayIDs { private let exactIDs: Set init(_ gatewayIDs: [String]) { self.exactIDs = Set(gatewayIDs.map { Data($0.utf8) }) } func contains(_ gatewayID: String) -> Bool { self.exactIDs.contains(Data(gatewayID.utf8)) } } /// Installation-wide storage for every paired gateway. /// /// Gateway-derived snapshots and client-owned work deliberately live in /// separate files. The cache may be rebuilt at any time; client state uses /// forward migrations and is never erased as a cache-repair strategy. public final class OpenClawClientDatabases: @unchecked Sendable { public static let gatewayCacheFilename = "gateway-cache.sqlite" public static let clientStateFilename = "client-state.sqlite" static let gatewayCacheFormatVersion = 1 public let directoryURL: URL let cacheQueue: DatabaseQueue let stateQueue: DatabaseQueue let outboxChangeHub = OutboxChangeHub() private let legacyDirectoryURLs: [URL] public init( directoryURL: URL, legacyDirectoryURLs: [URL] = [], registeredGatewayIDs: [String]? = nil) throws { self.directoryURL = directoryURL self.legacyDirectoryURLs = legacyDirectoryURLs try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) let stateURL = directoryURL.appendingPathComponent(Self.clientStateFilename, isDirectory: false) self.stateQueue = try Self.openStateDatabase(at: stateURL) self.cacheQueue = try Self.openRepairableCacheDatabase( at: directoryURL.appendingPathComponent(Self.gatewayCacheFilename, isDirectory: false)) let exactRegisteredGatewayIDs = registeredGatewayIDs.map(RegisteredGatewayIDs.init) self.resolvePendingGatewayRemovals(registeredGatewayIDs: exactRegisteredGatewayIDs) self.importLegacyDatabases(registeredGatewayIDs: exactRegisteredGatewayIDs) } public func store(gatewayID: String) -> OpenClawChatSQLiteTranscriptCache { OpenClawChatSQLiteTranscriptCache(databases: self, gatewayID: gatewayID) } /// Retries one-time import and forgotten-gateway cleanup. iOS calls this /// again on foreground because old complete-protection files may have been /// unreadable during a locked background launch. public func retryLegacyImport(registeredGatewayIDs: [String]? = nil) { self.importLegacyDatabases( registeredGatewayIDs: registeredGatewayIDs.map(RegisteredGatewayIDs.init)) } public func loadSessionRoutingIdentity( gatewayID: String) -> OpenClawChatSessionRoutingIdentity? { do { return try self.stateQueue.read { db in guard let row = try Row.fetchOne( db, sql: """ SELECT scope, main_session_key, default_agent_id FROM gateway_routing_identity WHERE gateway_id = ? """, arguments: [gatewayID]) else { return nil } return OpenClawChatSessionRoutingIdentity( scope: row["scope"], mainSessionKey: row["main_session_key"], defaultAgentID: row["default_agent_id"]) } } catch { databaseLogger.error("client state routing read failed: \(error.localizedDescription, privacy: .public)") return nil } } /// Removes one forgotten gateway without disturbing the other gateways in /// either installation-wide database. public func removeGatewayData(gatewayID: String) throws { try self.stageGatewayRemoval(gatewayID: gatewayID) try self.commitGatewayRemoval(gatewayID: gatewayID) } /// Stages the cross-owner forget transaction before pairing metadata is /// removed. No gateway payload is deleted until the registry owner commits. public func stageGatewayRemoval(gatewayID: String) throws { let gatewayHash = Self.gatewayIdentityHash(gatewayID) let existingPhase = try stateQueue.read { db in try Int.fetchOne( db, sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", arguments: [gatewayHash]) } if existingPhase == GatewayRemovalPhase.committing.rawValue { try self.commitGatewayRemoval(gatewayID: gatewayID) } else if existingPhase == GatewayRemovalPhase.scrubbing.rawValue { try self.finishGatewayRemovalScrub(gatewayHash: gatewayHash) } try self.rejectPreservedSharedLegacyDatabase() try self.stateQueue.write { db in try db.execute( sql: """ INSERT INTO forgotten_gateways( gateway_hash, gateway_id, forgotten_at, cleanup_phase, restore_finalized ) VALUES (?, ?, ?, ?, 0) ON CONFLICT(gateway_hash) DO UPDATE SET gateway_id = excluded.gateway_id, forgotten_at = CASE WHEN forgotten_gateways.cleanup_phase = 0 THEN forgotten_gateways.forgotten_at ELSE excluded.forgotten_at END, cleanup_phase = excluded.cleanup_phase, restore_finalized = CASE WHEN forgotten_gateways.cleanup_phase = 0 THEN 1 ELSE forgotten_gateways.restore_finalized END WHERE forgotten_gateways.cleanup_phase NOT IN (2, 3) """, arguments: [ gatewayHash, gatewayID, Date().timeIntervalSince1970, GatewayRemovalPhase.staged.rawValue, ]) } } /// Commits a staged forget after the registry owner has removed pairing /// metadata. A failed commit remains staged for startup reconciliation. public func commitGatewayRemoval(gatewayID: String) throws { let gatewayHash = Self.gatewayIdentityHash(gatewayID) let existingPhase = try stateQueue.read { db in try Int.fetchOne( db, sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", arguments: [gatewayHash]) } if existingPhase == GatewayRemovalPhase.scrubbing.rawValue { try self.finishGatewayRemovalScrub(gatewayHash: gatewayHash) return } // Mark the irreversible phase in the same transaction that deletes // client state. Recovery must finish this phase even if pairing was // preserved (for example, a cache-only purge). try self.stateQueue.write { db in guard let phase = try Int.fetchOne( db, sql: """ SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ? AND gateway_id = ? """, arguments: [gatewayHash, gatewayID]), phase == GatewayRemovalPhase.staged.rawValue || phase == GatewayRemovalPhase.committing.rawValue else { throw DatabaseError(message: "gateway removal was not staged") } if phase == GatewayRemovalPhase.staged.rawValue { try db.execute( sql: """ UPDATE forgotten_gateways SET cleanup_phase = ? WHERE gateway_hash = ? AND gateway_id = ? AND cleanup_phase = ? """, arguments: [ GatewayRemovalPhase.committing.rawValue, gatewayHash, gatewayID, GatewayRemovalPhase.staged.rawValue, ]) } try db.execute(sql: "DELETE FROM outbox_commands WHERE gateway_id = ?", arguments: [gatewayID]) try db.execute(sql: "DELETE FROM outbox_branch_scopes WHERE gateway_id = ?", arguments: [gatewayID]) try db.execute( sql: "DELETE FROM gateway_routing_identity WHERE gateway_id = ?", arguments: [gatewayID]) } try self.cacheQueue.write { db in try db.execute(sql: "DELETE FROM cached_sessions WHERE gateway_id = ?", arguments: [gatewayID]) try db.execute(sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?", arguments: [gatewayID]) } try self.removeLegacyGatewayDatabaseFiles(gatewayID: gatewayID) // secure_delete scrubs deleted cells; truncating both WALs removes // pre-delete frames while preserving every other gateway's rows. _ = try self.cacheQueue.writeWithoutTransaction { db in try db.checkpoint(.truncate) } _ = try self.stateQueue.writeWithoutTransaction { db in try db.checkpoint(.truncate) } try self.stateQueue.write { db in try db.execute( sql: """ UPDATE forgotten_gateways SET gateway_id = NULL, cleanup_phase = 3, restore_finalized = 0 WHERE gateway_hash = ? AND cleanup_phase = 2 """, arguments: [gatewayHash]) } try self.finishGatewayRemovalScrub(gatewayHash: gatewayHash) } /// Cancels an uncommitted forget when the registry owner could not remove /// the pairing. Since staging deletes no payload, the gateway stays intact. public func cancelGatewayRemoval(gatewayID: String) throws { let gatewayHash = Self.gatewayIdentityHash(gatewayID) try self.stateQueue.write { db in // A repeated forget temporarily expands a finalized hash-only // tombstone. Cancellation must collapse it again, not erase it. try db.execute( sql: """ UPDATE forgotten_gateways SET gateway_id = NULL, cleanup_phase = 0, restore_finalized = 0 WHERE gateway_hash = ? AND gateway_id = ? AND cleanup_phase = 1 AND restore_finalized = 1 """, arguments: [gatewayHash, gatewayID]) try db.execute( sql: """ DELETE FROM forgotten_gateways WHERE gateway_hash = ? AND gateway_id = ? AND cleanup_phase = 1 AND restore_finalized = 0 """, arguments: [gatewayHash, gatewayID]) } _ = try self.stateQueue.writeWithoutTransaction { db in try db.checkpoint(.truncate) } } /// Resolves a crash between staging, registry removal, and commit. A still /// registered gateway cancels safely; an absent gateway finishes erasure. /// Without an authoritative registry, only irreversible commits advance; /// cancelable stages remain untouched. public func resolvePendingGatewayRemovals(registeredGatewayIDs: [String]? = nil) { self.resolvePendingGatewayRemovals( registeredGatewayIDs: registeredGatewayIDs.map(RegisteredGatewayIDs.init)) } private func resolvePendingGatewayRemovals(registeredGatewayIDs: RegisteredGatewayIDs?) { let pending: [Row] do { pending = try self.stateQueue.read { db in try Row.fetchAll( db, sql: """ SELECT gateway_hash, gateway_id, cleanup_phase FROM forgotten_gateways WHERE cleanup_phase IN (1, 2, 3) ORDER BY gateway_hash """) } } catch { databaseLogger.error( "pending gateway removal read failed: \(error.localizedDescription, privacy: .public)") return } for row in pending { let gatewayHash: String = row["gateway_hash"] let gatewayID: String? = row["gateway_id"] let phase: Int = row["cleanup_phase"] do { if phase == GatewayRemovalPhase.scrubbing.rawValue { try self.finishGatewayRemovalScrub(gatewayHash: gatewayHash) } else if phase == GatewayRemovalPhase.committing.rawValue, let gatewayID { try self.commitGatewayRemoval(gatewayID: gatewayID) } else if let gatewayID, let registeredGatewayIDs { if registeredGatewayIDs.contains(gatewayID) { try self.cancelGatewayRemoval(gatewayID: gatewayID) } else { try self.commitGatewayRemoval(gatewayID: gatewayID) } } } catch { let reason = error.localizedDescription databaseLogger.error( "pending removal \(gatewayHash.prefix(12), privacy: .public) failed: \(reason, privacy: .public)") } } } /// Fail closed while an irreversible or cancelable removal marker still /// exists. Callers use this after recovery before exposing a new writable /// facade for the same gateway. public func hasPendingGatewayRemoval(gatewayID: String) -> Bool { do { let gatewayHash = Self.gatewayIdentityHash(gatewayID) return try self.stateQueue.read { db in try Int.fetchOne( db, sql: """ SELECT 1 FROM forgotten_gateways WHERE gateway_hash = ? AND cleanup_phase IN (1, 2, 3) """, arguments: [gatewayHash]) != nil } } catch { let reason = error.localizedDescription databaseLogger.error( "pending gateway removal check failed: \(reason, privacy: .public)") return true } } /// A hash-only marker survives until the checkpoint that physically drops /// old WAL frames. If that checkpoint fails, startup can retry without /// retaining the raw gateway identifier. private func finishGatewayRemovalScrub(gatewayHash: String) throws { _ = try self.stateQueue.writeWithoutTransaction { db in try db.checkpoint(.truncate) } try self.stateQueue.write { db in try db.execute( sql: """ UPDATE forgotten_gateways SET cleanup_phase = 0 WHERE gateway_hash = ? AND cleanup_phase = 3 """, arguments: [gatewayHash]) } } /// Closes both installation-wide handles before a full reset removes the /// files. Gateway-scoped deletion keeps the shared handles open. public func close() throws { self.outboxChangeHub.finish() try self.cacheQueue.close() try self.stateQueue.close() } /// Startup-only removal after all store/container references have been /// released. Sidecars are named explicitly so WAL pages cannot survive a /// full onboarding reset. public static func removeDatabaseFiles(in directoryURL: URL) throws { for filename in [self.gatewayCacheFilename, self.clientStateFilename] { try self.removeDatabaseFilesChecked( at: directoryURL.appendingPathComponent(filename, isDirectory: false)) } for legacyURL in legacyDatabaseURLs(in: directoryURL) { try self.removeDatabaseFilesChecked(at: legacyURL) } } static func removeDatabaseFiles(at databaseURL: URL) { let fileManager = FileManager.default try? fileManager.removeItem(at: databaseURL) for suffix in ["-wal", "-shm", "-journal"] { try? fileManager.removeItem(at: URL(fileURLWithPath: databaseURL.path + suffix)) } } static func legacyPerGatewayDatabaseURL(gatewayID: String, directoryURL: URL) -> URL { directoryURL.appendingPathComponent("\(self.gatewayIdentityHash(gatewayID)).sqlite", isDirectory: false) } static func gatewayIdentityHash(_ gatewayID: String) -> String { SHA256.hash(data: Data(gatewayID.utf8)) .map { String(format: "%02x", $0) } .joined() } private static func removeDatabaseFilesChecked(at databaseURL: URL) throws { let fileManager = FileManager.default for url in [databaseURL] + ["-wal", "-shm", "-journal"].map({ suffix in URL(fileURLWithPath: databaseURL.path + suffix) }) where fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } } private func removeLegacyGatewayDatabaseFiles(gatewayID: String) throws { let directories = Set([directoryURL] + self.legacyDirectoryURLs) for directoryURL in directories { try Self.removeDatabaseFilesChecked(at: Self.legacyPerGatewayDatabaseURL( gatewayID: gatewayID, directoryURL: directoryURL)) } } private func rejectPreservedSharedLegacyDatabase() throws { let directories = Set([directoryURL] + self.legacyDirectoryURLs) guard directories.contains(where: { directoryURL in FileManager.default.fileExists( atPath: directoryURL.appendingPathComponent("chat-cache.sqlite").path) }) else { return } // A shared legacy file may contain several gateways. If startup could // not import it, targeted erasure cannot be proven without data loss. throw DatabaseError(message: "shared legacy database blocks targeted gateway removal") } } extension OpenClawClientDatabases { // MARK: - Schema ownership private static func configuration(label: String) -> Configuration { var configuration = Configuration() configuration.label = label // Use the platform app-container default so background cache/outbox // work is not coupled to iOS protected-data availability. configuration.journalMode = .wal configuration.busyMode = .timeout(5) configuration.prepareDatabase { db in try db.execute(sql: "PRAGMA secure_delete = ON") } return configuration } private static func openStateDatabase(at url: URL) throws -> DatabaseQueue { let queue = try DatabaseQueue( path: url.path, configuration: self.configuration(label: "OpenClaw.client-state")) var migrator = DatabaseMigrator() migrator.registerMigration("client-state-v1") { db in try db.execute(sql: """ CREATE TABLE forgotten_gateways( gateway_hash TEXT NOT NULL PRIMARY KEY, gateway_id TEXT, forgotten_at REAL NOT NULL, cleanup_phase INTEGER NOT NULL CHECK(cleanup_phase IN (0, 1, 2, 3)), restore_finalized INTEGER NOT NULL DEFAULT 0 CHECK(restore_finalized IN (0, 1)), CHECK((cleanup_phase IN (1, 2) AND gateway_id IS NOT NULL) OR (cleanup_phase IN (0, 3) AND gateway_id IS NULL AND restore_finalized = 0)) ); CREATE TABLE gateway_routing_identity( gateway_id TEXT NOT NULL PRIMARY KEY, scope TEXT NOT NULL, main_session_key TEXT NOT NULL, default_agent_id TEXT NOT NULL, updated_at REAL NOT NULL ); CREATE TABLE outbox_commands( enqueue_sequence INTEGER PRIMARY KEY AUTOINCREMENT, gateway_id TEXT NOT NULL, client_uuid TEXT NOT NULL, session_key TEXT NOT NULL, delivery_session_key TEXT NOT NULL, routing_contract TEXT NOT NULL, agent_id TEXT NOT NULL, text TEXT NOT NULL, thinking TEXT NOT NULL, created_at REAL NOT NULL, status TEXT NOT NULL CHECK(status IN ( 'queued', 'sending', 'awaiting_confirmation', 'failed' )), retry_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', attachment_bytes INTEGER NOT NULL DEFAULT 0, UNIQUE(gateway_id, client_uuid) ); CREATE INDEX outbox_commands_delivery_order ON outbox_commands(gateway_id, created_at, enqueue_sequence); CREATE TABLE outbox_attachments( gateway_id TEXT NOT NULL, command_id TEXT NOT NULL, position INTEGER NOT NULL, type TEXT NOT NULL, mime_type TEXT NOT NULL, file_name TEXT NOT NULL, payload BLOB NOT NULL, duration_seconds REAL, PRIMARY KEY(gateway_id, command_id, position), FOREIGN KEY(gateway_id, command_id) REFERENCES outbox_commands(gateway_id, client_uuid) ON DELETE CASCADE ); """) } // Additive branch ownership remains local client state. Older app // builds ignore these fields while newer builds fail replay closed. migrator.registerMigration("client-state-branch-ownership-v2") { db in try db.execute(sql: """ ALTER TABLE outbox_commands ADD COLUMN branch_epoch INTEGER NOT NULL DEFAULT 0; ALTER TABLE outbox_commands ADD COLUMN attempt_version INTEGER NOT NULL DEFAULT 1; ALTER TABLE outbox_commands ADD COLUMN parked_was_accepted INTEGER NOT NULL DEFAULT 0; CREATE TABLE outbox_branch_scopes( gateway_id TEXT NOT NULL, session_key TEXT NOT NULL, agent_id TEXT NOT NULL DEFAULT '', branch_epoch INTEGER NOT NULL DEFAULT 0, last_active_leaf_id TEXT, switch_pending_since REAL, needs_reconciliation INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(gateway_id, session_key, agent_id) ); """) } migrator.registerMigration("client-state-branch-revision-v3") { db in try db.execute(sql: """ ALTER TABLE outbox_branch_scopes ADD COLUMN branch_state_revision INTEGER NOT NULL DEFAULT 0; """) } migrator.registerMigration("client-state-agent-id-v4") { db in try db.execute(sql: "UPDATE outbox_commands SET agent_id = '' WHERE agent_id IS NULL") try db.execute(sql: "UPDATE outbox_branch_scopes SET agent_id = '' WHERE agent_id IS NULL") } migrator.registerMigration("client-state-outbox-attempt-scope-v5") { db in try db .execute( sql: "ALTER TABLE outbox_commands ADD COLUMN had_unacknowledged_send INTEGER NOT NULL DEFAULT 0") // Legacy rows with prior attempts may have reached the gateway before a // transport failure; without this evidence a post-park retry would reuse an // idempotency key the old branch may already own. try db.execute( sql: """ UPDATE outbox_commands SET had_unacknowledged_send = 1 WHERE retry_count > 0 OR status IN ('sending', 'awaiting_confirmation') """) try db.execute(sql: """ INSERT OR IGNORE INTO outbox_branch_scopes( gateway_id, session_key, agent_id, branch_epoch, needs_reconciliation ) SELECT gateway_id, session_key, agent_id, 0, 1 FROM outbox_commands """) } try migrator.migrate(queue) return queue } private static func openRepairableCacheDatabase(at url: URL) throws -> DatabaseQueue { do { let queue = try DatabaseQueue( path: url.path, configuration: self.configuration(label: "OpenClaw.gateway-cache")) try self.prepareCacheSchema(queue) return queue } catch { // This file contains gateway snapshots only. A format mismatch or // corruption is repaired by rebuilding, never by migrating rows. self.removeDatabaseFiles(at: url) let queue = try DatabaseQueue( path: url.path, configuration: self.configuration(label: "OpenClaw.gateway-cache")) try self.prepareCacheSchema(queue) return queue } } private static func prepareCacheSchema(_ queue: DatabaseQueue) throws { try queue.write { db in let currentVersion: Int? = if try db.tableExists("cache_metadata") { try Int.fetchOne(db, sql: "SELECT format_version FROM cache_metadata WHERE id = 1") } else { nil } if let currentVersion, currentVersion != self.gatewayCacheFormatVersion { throw GatewayCacheFormatMismatch() } if currentVersion == nil, try db.tableExists("cached_sessions") || db.tableExists("cached_transcripts") || db.tableExists("cached_messages") { throw GatewayCacheFormatMismatch() } try db.execute(sql: """ CREATE TABLE IF NOT EXISTS cache_metadata( id INTEGER NOT NULL PRIMARY KEY CHECK(id = 1), format_version INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS cached_sessions( gateway_id TEXT NOT NULL, session_key TEXT NOT NULL, position INTEGER NOT NULL, updated_at REAL NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY(gateway_id, session_key) ); CREATE INDEX IF NOT EXISTS cached_sessions_order ON cached_sessions(gateway_id, position); CREATE TABLE IF NOT EXISTS cached_transcripts( gateway_id TEXT NOT NULL, session_key TEXT NOT NULL, agent_id TEXT NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY(gateway_id, session_key, agent_id) ); CREATE INDEX IF NOT EXISTS cached_transcripts_recency ON cached_transcripts(gateway_id, updated_at DESC); CREATE TABLE IF NOT EXISTS cached_messages( gateway_id TEXT NOT NULL, session_key TEXT NOT NULL, agent_id TEXT NOT NULL, position INTEGER NOT NULL, timestamp_ms REAL, idempotency_key TEXT, payload_json TEXT NOT NULL, PRIMARY KEY(gateway_id, session_key, agent_id, position), FOREIGN KEY(gateway_id, session_key, agent_id) REFERENCES cached_transcripts(gateway_id, session_key, agent_id) ON DELETE CASCADE ); INSERT OR REPLACE INTO cache_metadata(id, format_version) VALUES (1, \(self.gatewayCacheFormatVersion)); """) } } } extension OpenClawClientDatabases { // MARK: - One-time legacy import private struct LegacySnapshot { var commands: [LegacyCommand] var routingIdentities: [LegacyRoutingIdentity] } private struct LegacyCommand { var gatewayID: String var id: String var sessionKey: String var deliverySessionKey: String var routingContract: String var agentID: String var text: String var attachments: [OpenClawChatOutboxAttachment] var thinking: String var createdAt: Double var status: String var retryCount: Int var lastError: String } private struct LegacyRoutingIdentity { var gatewayID: String var scope: String var mainSessionKey: String var defaultAgentID: String var updatedAt: Double } private func importLegacyDatabases(registeredGatewayIDs: RegisteredGatewayIDs?) { let directories = [self.directoryURL] + self.legacyDirectoryURLs let legacyURLs = Set(directories.flatMap(Self.legacyDatabaseURLs(in:))) for legacyURL in legacyURLs.sorted(by: { $0.path < $1.path }) { do { guard let snapshot = try Self.readLegacySnapshot(at: legacyURL) else { continue } let legacyGatewayIDs = Set( snapshot.commands.map(\.gatewayID) + snapshot.routingIdentities.map(\.gatewayID)) let ownedSnapshot: LegacySnapshot = if let registeredGatewayIDs { LegacySnapshot( commands: snapshot.commands.filter { registeredGatewayIDs.contains($0.gatewayID) }, routingIdentities: snapshot.routingIdentities.filter { registeredGatewayIDs.contains($0.gatewayID) }) } else { snapshot } try self.writeLegacySnapshot(ownedSnapshot) // Preserve bytes for unregistered gateways rather than // importing or destroying state whose ownership is unknown. let forgottenGatewayHashes = try forgottenGatewayHashesForLegacyImport() let allLegacyGatewaysAccountedFor = legacyGatewayIDs.allSatisfy { gatewayID in registeredGatewayIDs?.contains(gatewayID) == true || forgottenGatewayHashes.contains(Self.gatewayIdentityHash(gatewayID)) } if registeredGatewayIDs == nil || allLegacyGatewaysAccountedFor { Self.removeDatabaseFiles(at: legacyURL) } } catch { // The new stores remain usable, but unknown/corrupt durable // bytes stay untouched for a future compatible importer. let filename = legacyURL.lastPathComponent let reason = error.localizedDescription databaseLogger.error( "legacy import failed: \(filename, privacy: .public): \(reason, privacy: .public)") } } } private static func legacyDatabaseURLs(in directoryURL: URL) -> [URL] { guard let urls = try? FileManager.default.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return [] } return urls.filter { url in let name = url.lastPathComponent guard name.hasSuffix(".sqlite"), name != self.gatewayCacheFilename, name != self.clientStateFilename else { return false } if name == "chat-cache.sqlite" { return true } let stem = String(name.dropLast(".sqlite".count)) return stem.count == 64 && stem.allSatisfy(\.isHexDigit) }.sorted { $0.lastPathComponent < $1.lastPathComponent } } private static func readLegacySnapshot(at url: URL) throws -> LegacySnapshot? { var configuration = Configuration() configuration.label = "OpenClaw.legacy-chat-import" configuration.readonly = true configuration.busyMode = .timeout(5) let queue = try DatabaseQueue(path: url.path, configuration: configuration) return try queue.read { db in let version = try Int.fetchOne(db, sql: "PRAGMA user_version") ?? 0 guard (1...6).contains(version) else { return nil } var snapshot = LegacySnapshot(commands: [], routingIdentities: []) if try db.tableExists("outbox_commands") { let columns = try Set(db.columns(in: "outbox_commands").map(\.name)) func expression(_ name: String, fallback: String) -> String { columns.contains(name) ? name : fallback } let rows = try Row.fetchAll(db, sql: """ SELECT client_uuid, gateway_id, session_key, \(expression("delivery_session_key", fallback: "''")) AS delivery_session_key, \(expression("routing_contract", fallback: "''")) AS routing_contract, \(expression("agent_id", fallback: "''")) AS agent_id, text, \(expression("attachments", fallback: "'[]'")) AS attachments, thinking, created_at, status, retry_count, last_error FROM outbox_commands ORDER BY created_at, id """) for row in rows { let attachmentsJSON: String = row["attachments"] let attachments = try JSONDecoder().decode( [OpenClawChatOutboxAttachment].self, from: Data(attachmentsJSON.utf8)) let originalStatus: String = row["status"] guard OpenClawChatOutboxCommand.Status(rawValue: originalStatus) != nil else { throw DatabaseError(message: "unknown legacy outbox status") } let routingContract: String = row["routing_contract"] let originalError: String = row["last_error"] let lacksVerifiedTarget = routingContract.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let status = lacksVerifiedTarget ? OpenClawChatOutboxCommand.Status.failed.rawValue : originalStatus let lastError: String = if lacksVerifiedTarget { if originalStatus == OpenClawChatOutboxCommand.Status.sending.rawValue || originalStatus == OpenClawChatOutboxCommand.Status.awaitingConfirmation.rawValue || originalError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError { OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError } else { OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError } } else { originalError } snapshot.commands.append(LegacyCommand( gatewayID: row["gateway_id"], id: row["client_uuid"], sessionKey: row["session_key"], deliverySessionKey: lacksVerifiedTarget ? "" : row["delivery_session_key"], routingContract: lacksVerifiedTarget ? "" : routingContract, agentID: lacksVerifiedTarget ? "" : row["agent_id"], text: row["text"], attachments: attachments, thinking: row["thinking"], createdAt: row["created_at"], status: status, retryCount: row["retry_count"], lastError: lastError)) } } if try db.tableExists("gateway_routing_identity") { let rows = try Row.fetchAll(db, sql: """ SELECT gateway_id, scope, main_session_key, default_agent_id, updated_at FROM gateway_routing_identity """) snapshot.routingIdentities = rows.map { row in LegacyRoutingIdentity( gatewayID: row["gateway_id"], scope: row["scope"], mainSessionKey: row["main_session_key"], defaultAgentID: row["default_agent_id"], updatedAt: row["updated_at"]) } } return snapshot } } private func writeLegacySnapshot(_ snapshot: LegacySnapshot) throws { try self.stateQueue.write { db in let forgottenGatewayHashes = try Set(String.fetchAll( db, sql: """ SELECT gateway_hash FROM forgotten_gateways WHERE cleanup_phase IN (0, 2, 3) OR restore_finalized = 1 """)) for identity in snapshot.routingIdentities where !forgottenGatewayHashes.contains(Self.gatewayIdentityHash(identity.gatewayID)) { try db.execute( sql: """ INSERT INTO gateway_routing_identity( gateway_id, scope, main_session_key, default_agent_id, updated_at ) VALUES (?, ?, ?, ?, ?) ON CONFLICT(gateway_id) DO UPDATE SET scope = excluded.scope, main_session_key = excluded.main_session_key, default_agent_id = excluded.default_agent_id, updated_at = excluded.updated_at WHERE excluded.updated_at > gateway_routing_identity.updated_at """, arguments: [ identity.gatewayID, identity.scope, identity.mainSessionKey, identity.defaultAgentID, identity.updatedAt, ]) } for command in snapshot.commands where !forgottenGatewayHashes.contains(Self.gatewayIdentityHash(command.gatewayID)) { let attachmentBytes = command.attachments.reduce(0) { $0 + $1.data.count } try db.execute( sql: """ INSERT OR IGNORE INTO outbox_commands( gateway_id, client_uuid, session_key, delivery_session_key, routing_contract, agent_id, text, thinking, created_at, status, retry_count, last_error, attachment_bytes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ command.gatewayID, command.id, command.sessionKey, command.deliverySessionKey, command.routingContract, command.agentID, command.text, command.thinking, command.createdAt, command.status, command.retryCount, command.lastError, attachmentBytes, ]) guard db.changesCount > 0 else { continue } try db.execute( sql: """ INSERT OR IGNORE INTO outbox_branch_scopes( gateway_id, session_key, agent_id, branch_epoch, needs_reconciliation ) VALUES (?, ?, ?, 0, 1) """, arguments: [command.gatewayID, command.sessionKey, command.agentID]) for (position, attachment) in command.attachments.enumerated() { try db.execute( sql: """ INSERT INTO outbox_attachments( gateway_id, command_id, position, type, mime_type, file_name, payload, duration_seconds ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ command.gatewayID, command.id, position, attachment.type, attachment.mimeType, attachment.fileName, attachment.data, attachment.durationSeconds, ]) } } } } private func forgottenGatewayHashesForLegacyImport() throws -> Set { try self.stateQueue.read { db in try Set(String.fetchAll( db, sql: """ SELECT gateway_hash FROM forgotten_gateways WHERE cleanup_phase IN (0, 2, 3) OR restore_finalized = 1 """)) } } }