mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 19:01:35 +00:00
926 lines
42 KiB
Swift
926 lines
42 KiB
Swift
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<Data>
|
|
|
|
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
|
|
ON UPDATE 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
|
|
""")
|
|
}
|
|
migrator.registerMigration("client-state-outbox-attachment-rekey-v6") { db in
|
|
try db.execute(sql: """
|
|
CREATE TABLE outbox_attachments_v6(
|
|
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
|
|
ON UPDATE CASCADE
|
|
);
|
|
INSERT INTO outbox_attachments_v6(
|
|
gateway_id, command_id, position, type, mime_type, file_name, payload, duration_seconds
|
|
)
|
|
SELECT gateway_id, command_id, position, type, mime_type, file_name, payload, duration_seconds
|
|
FROM outbox_attachments;
|
|
DROP TABLE outbox_attachments;
|
|
ALTER TABLE outbox_attachments_v6 RENAME TO outbox_attachments;
|
|
""")
|
|
}
|
|
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<String> {
|
|
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
|
|
"""))
|
|
}
|
|
}
|
|
}
|