diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index e6990c9d77d..cdc23b80bfb 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -22947,7 +22947,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1283, + "line": 1291, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at %1$@:%2$@. Verify Tailscale Serve is enabled and publishes this Gateway.", "surface": "apple", @@ -22955,7 +22955,7 @@ }, { "kind": "ui-localized-call", - "line": 1292, + "line": 1300, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN.", "surface": "apple", @@ -22963,7 +22963,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1298, + "line": 1306, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "TLS fingerprint verification timed out for %1$@:%2$@. Secure endpoint was reached, but TLS did not finish in time.", "surface": "apple", @@ -22971,7 +22971,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1306, + "line": 1314, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "No secure gateway endpoint was detected at %1$@:%2$@. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "apple", @@ -22979,7 +22979,7 @@ }, { "kind": "ui-localized-call", - "line": 1316, + "line": 1324, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Could not read the TLS certificate from %1$@:%2$@.", "surface": "apple", @@ -23867,7 +23867,7 @@ }, { "kind": "ui-localized-call", - "line": 4140, + "line": 4262, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -23875,7 +23875,7 @@ }, { "kind": "ui-localized-call", - "line": 4141, + "line": 4263, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -23883,7 +23883,7 @@ }, { "kind": "ui-localized-call", - "line": 4856, + "line": 4978, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -23891,7 +23891,7 @@ }, { "kind": "ui-localized-call", - "line": 4857, + "line": 4979, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -23899,7 +23899,7 @@ }, { "kind": "conditional-branch", - "line": 5209, + "line": 5331, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -23907,7 +23907,7 @@ }, { "kind": "conditional-branch", - "line": 5209, + "line": 5331, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -23915,7 +23915,7 @@ }, { "kind": "conditional-branch", - "line": 6379, + "line": 6501, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "No chat messages yet", "surface": "apple", @@ -23923,7 +23923,7 @@ }, { "kind": "conditional-branch", - "line": 6578, + "line": 6700, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -23931,7 +23931,7 @@ }, { "kind": "conditional-branch", - "line": 6578, + "line": 6700, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -23939,7 +23939,7 @@ }, { "kind": "conditional-branch", - "line": 8987, + "line": 9109, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval", "surface": "apple", @@ -23947,7 +23947,7 @@ }, { "kind": "conditional-branch", - "line": 8987, + "line": 9109, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "This approval was already", "surface": "apple", @@ -23955,7 +23955,7 @@ }, { "kind": "conditional-branch", - "line": 8993, + "line": 9115, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "This approval was already set to Always Allow.", "surface": "apple", @@ -23963,7 +23963,7 @@ }, { "kind": "conditional-branch", - "line": 8994, + "line": 9116, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval set to Always Allow.", "surface": "apple", @@ -23971,7 +23971,7 @@ }, { "kind": "conditional-branch", - "line": 10331, + "line": 10453, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "\\(urlText.prefix(500))…", "surface": "apple", @@ -38857,22 +38857,6 @@ "surface": "apple", "id": "native.apple.f80bb83a659d23f4" }, - { - "kind": "conditional-branch", - "line": 1399, - "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift", - "source": "INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at)\nSELECT gateway_id, session_key, agent_id, payload, updated_at\nFROM cached_transcripts_pre_v3", - "surface": "apple", - "id": "native.apple.c4e808a2ec8d49f5" - }, - { - "kind": "conditional-branch", - "line": 1404, - "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift", - "source": "INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at)\nSELECT gateway_id, session_key, '', payload, updated_at\nFROM cached_transcripts_pre_v3\nWHERE lower(trim(session_key)) GLOB 'agent:*:*'", - "surface": "apple", - "id": "native.apple.4e7e29be3f05b828" - }, { "kind": "conditional-branch", "line": 80, diff --git a/apps/ios/Resources/Licenses/GRDB.txt b/apps/ios/Resources/Licenses/GRDB.txt new file mode 100644 index 00000000000..10b828e0be8 --- /dev/null +++ b/apps/ios/Resources/Licenses/GRDB.txt @@ -0,0 +1,7 @@ +Copyright (C) 2015-2025 Gwendal Roue + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index 2fcd95dcc27..5cb4b925ce7 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -476,8 +476,8 @@ struct ChatProTab: View { } private func makeChatViewModel(sessionKey: String) -> OpenClawChatViewModel { - // One store instance backs both seams so the transcript cache and the - // offline outbox share a single SQLite connection. + // One gateway facade backs both seams while routing cache and outbox + // operations to their separate installation-wide databases. let offlineStore = self.appModel.makeChatOfflineStore() let voiceNoteRecorder = self.appModel.voiceNoteRecorder return OpenClawChatViewModel( diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index e60ea7ca534..5c0639ccef8 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -351,7 +351,7 @@ struct SettingsProTab: View { titleVisibility: .visible) { Button(role: .destructive) { - self.forgetPendingGateway() + Task { await self.forgetPendingGateway() } } label: { Text("Forget Gateway") .font(OpenClawType.subheadSemiBold) diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index f2eb2b1a304..6f461c3cb40 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -149,10 +149,10 @@ extension SettingsProTab { self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false) } - func forgetPendingGateway() { + func forgetPendingGateway() async { guard let entry = self.pendingForgetGateway else { return } self.pendingForgetGateway = nil - guard self.gatewayController.forgetGateway(stableID: entry.stableID) else { + guard await self.gatewayController.forgetGateway(stableID: entry.stableID) else { self.setupStatusText = String( format: String(localized: "Could not forget %@."), entry.name) diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 354317b5729..e78b39c2761 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -112,7 +112,7 @@ final class GatewayConnectionController { @ObservationIgnored private var pendingAutoConnectGeneration: UInt64? @ObservationIgnored private var pendingAutoConnectSuppressionGeneration: UInt64? @ObservationIgnored private var pendingForgetCleanups: [ - GatewayStableIdentifier.Key: (id: UUID, task: Task) + GatewayStableIdentifier.Key: (id: UUID, task: Task) ] = [:] private var pendingConnectionStableID: String? private let tcpReachabilityProbe: GatewayTCPReachabilityProbe @@ -529,16 +529,27 @@ final class GatewayConnectionController { } @discardableResult - func forgetGateway(stableID: String) -> Bool { + func forgetGateway(stableID: String) async -> Bool { guard let stableID = GatewayStableIdentifier.exact(stableID), let stableIDKey = GatewayStableIdentifier.key(stableID) else { return false } - if self.pendingForgetCleanups[stableIDKey] != nil { - return true + if let pending = self.pendingForgetCleanups[stableIDKey] { + return await pending.task.value } - guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else { - return false + let cleanupID = UUID() + let cleanupTask = Task { @MainActor [weak self] in + guard let self else { return false } + return await self.performForgetGateway(stableID: stableID) } + self.pendingForgetCleanups[stableIDKey] = (cleanupID, cleanupTask) + let result = await cleanupTask.value + if self.pendingForgetCleanups[stableIDKey]?.id == cleanupID { + self.pendingForgetCleanups[stableIDKey] = nil + } + return result + } + + private func performForgetGateway(stableID: String) async -> Bool { if GatewayStableIdentifier.matches(self.pendingConnectionStableID, stableID) { let cancellationLease = self.cancelPendingConnectionAttempts() self.releaseAutoConnectSuppression(after: cancellationLease) @@ -554,6 +565,21 @@ final class GatewayConnectionController { self.appModel?.disconnectForgottenGateway( preservingPendingConnectAttempt: hasDifferentPendingTarget) } + if shouldDisconnect, let appModel = self.appModel { + await appModel.waitForGatewaySessionResetIfNeeded() + } + // Stage before touching pairing metadata. A crash is reconciled from + // the registry: still registered cancels, absent commits the erasure. + guard let appModel = self.appModel, + await appModel.stageChatOfflineDataRemoval(gatewayID: stableID) + else { return false } + guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else { + appModel.cancelChatOfflineDataRemoval(gatewayID: stableID) + return false + } + // Registry removal is the cross-owner commit point. Clear controller + // artifacts before database cleanup, which may fail or be recovered on + // a later foreground after the registry row is already gone. let instanceID = GatewaySettingsStore.currentInstanceID() self.clearLegacyManualGatewayDefaults(matching: stableID) GatewaySettingsStore.clearLegacyGatewaySelectors(stableID: stableID) @@ -568,25 +594,7 @@ final class GatewayConnectionController { } Self.clearDeviceAuthTokens(gatewayID: stableID) - let cleanupID = UUID() - let cleanupTask = Task { @MainActor [weak appModel] in - if let appModel { - if shouldDisconnect { - await appModel.waitForGatewaySessionResetIfNeeded() - // A handshake racing teardown can persist a token after the first cleanup. - Self.clearDeviceAuthTokens(gatewayID: stableID) - } - await appModel.purgeChatTranscriptCache(gatewayID: stableID) - } else if let databaseURL = NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID) { - OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL) - } - } - self.pendingForgetCleanups[stableIDKey] = (cleanupID, cleanupTask) - Task { @MainActor [weak self] in - await cleanupTask.value - guard self?.pendingForgetCleanups[stableIDKey]?.id == cleanupID else { return } - self?.pendingForgetCleanups[stableIDKey] = nil - } + _ = appModel.commitChatOfflineDataRemoval(gatewayID: stableID) return true } @@ -594,7 +602,7 @@ final class GatewayConnectionController { guard let stableIDKey = GatewayStableIdentifier.key(stableID), let pending = self.pendingForgetCleanups[stableIDKey] else { return } - await pending.task.value + _ = await pending.task.value if self.pendingForgetCleanups[stableIDKey]?.id == pending.id { self.pendingForgetCleanups[stableIDKey] = nil } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 6125d7f484e..1ac8b1cba2c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -10,6 +10,10 @@ import SwiftUI import UIKit import UserNotifications +private let clientDatabaseLogger = Logger( + subsystem: "ai.openclawfoundation.app", + category: "ClientDatabases") + /// Wrap errors without pulling non-Sendable types into async notification paths. private struct NotificationCallError: Error { let message: String @@ -587,6 +591,7 @@ final class NodeAppModel { @ObservationIgnored private var watchMessageRetryAttempts: [String: Int] = [:] @ObservationIgnored private var watchMessageRetryTask: Task? @ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport() + @ObservationIgnored private var clientDatabases: OpenClawClientDatabases? @ObservationIgnored private var chatTranscriptCachesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:] @ObservationIgnored private var chatSessionRoutingRestoreTask: Task? private var watchExecApprovalPromptsByID: [ExecApprovalIdentifier.Key: ExecApprovalPrompt] = [:] @@ -700,18 +705,29 @@ final class NodeAppModel { } private var chatTranscriptCacheGeneration = 0 + private var quarantinedChatOfflineGatewayIDs: Set = [] - /// Offline transcript cache plus durable command outbox, both scoped to - /// the paired gateway identity (one SQLite file per gateway, memoized so - /// retire/purge can close every open handle). Nil for fixture/unpaired - /// transports: no cache and no outbox. + /// Gateway-scoped facade over the installation-wide cache and client-state + /// databases. Nil for fixture/unpaired transports: no cache and no outbox. func makeChatOfflineStore() -> OpenClawChatSQLiteTranscriptCache? { - guard let gatewayID = self.chatTranscriptCacheGatewayID else { return nil } + guard let gatewayID = self.chatTranscriptCacheGatewayID, + !self.quarantinedChatOfflineGatewayIDs.contains(gatewayID) + else { return nil } + if self.clientDatabases == nil { + self.clientDatabases = Self.makeClientDatabases() + } + guard let clientDatabases else { return nil } + // The durable marker is authoritative across task suspension and app + // relaunch. Never expose even an existing facade while cleanup can + // still delete rows written through it. + guard !clientDatabases.hasPendingGatewayRemoval(gatewayID: gatewayID) else { + self.quarantinedChatOfflineGatewayIDs.insert(gatewayID) + return nil + } if let cache = self.chatTranscriptCachesByGatewayID[gatewayID] { return cache } - guard let databaseURL = Self.chatTranscriptCacheDatabaseURL(gatewayID: gatewayID) else { return nil } - let cache = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID) + let cache = clientDatabases.store(gatewayID: gatewayID) self.chatTranscriptCachesByGatewayID[gatewayID] = cache return cache } @@ -756,20 +772,67 @@ final class NodeAppModel { await cache.storeSessions(sessions) } - /// Delete one gateway's cache during bootstrap replacement, or the whole - /// disposable database during a full onboarding reset. The offline command - /// outbox shares each gateway's database file, so purging a cache also - /// drops that gateway's queued commands. - func purgeChatTranscriptCache(gatewayID: String? = nil) async { - if let gatewayID, !gatewayID.isEmpty { - guard let databaseURL = Self.chatTranscriptCacheDatabaseURL(gatewayID: gatewayID) else { return } - if let cache = self.chatTranscriptCachesByGatewayID[gatewayID] { - await cache.retire() - } - OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL) + /// Delete one forgotten gateway's cache and durable state, or both + /// installation-wide databases during a full onboarding reset. + func stageChatOfflineDataRemoval(gatewayID: String) async -> Bool { + if self.clientDatabases == nil { + self.clientDatabases = Self.makeClientDatabases() + } + guard let clientDatabases else { return false } + do { + try clientDatabases.stageGatewayRemoval(gatewayID: gatewayID) + await self.chatTranscriptCachesByGatewayID[gatewayID]?.retire() + return true + } catch { + clientDatabaseLogger.error( + "gateway removal staging failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + func commitChatOfflineDataRemoval(gatewayID: String) -> Bool { + guard let clientDatabases else { return false } + do { + try clientDatabases.commitGatewayRemoval(gatewayID: gatewayID) + self.quarantinedChatOfflineGatewayIDs.remove(gatewayID) self.chatTranscriptCachesByGatewayID.removeValue(forKey: gatewayID) self.chatTranscriptCacheGeneration &+= 1 - return + return true + } catch { + clientDatabaseLogger.error( + "gateway removal commit failed: \(error.localizedDescription, privacy: .public)") + // The removal may already be irreversible. Keep this gateway + // quarantined until foreground recovery proves no marker remains. + self.quarantinedChatOfflineGatewayIDs.insert(gatewayID) + self.chatTranscriptCachesByGatewayID.removeValue(forKey: gatewayID) + self.chatTranscriptCacheGeneration &+= 1 + return false + } + } + + func cancelChatOfflineDataRemoval(gatewayID: String) { + guard let clientDatabases else { return } + do { + try clientDatabases.cancelGatewayRemoval(gatewayID: gatewayID) + } catch { + clientDatabaseLogger.error( + "gateway removal cancellation failed: \(error.localizedDescription, privacy: .public)") + } + // The registry owner did not commit, so cleanup never crossed into the + // irreversible phase. Repair the retired live facade even if the + // cancellation transaction or its checkpoint must be retried later. + self.quarantinedChatOfflineGatewayIDs.remove(gatewayID) + if self.chatTranscriptCachesByGatewayID[gatewayID] != nil { + self.chatTranscriptCachesByGatewayID[gatewayID] = clientDatabases.store(gatewayID: gatewayID) + self.chatTranscriptCacheGeneration &+= 1 + } + } + + @discardableResult + func purgeChatTranscriptCache(gatewayID: String? = nil) async -> Bool { + if let gatewayID, !gatewayID.isEmpty { + guard await self.stageChatOfflineDataRemoval(gatewayID: gatewayID) else { return false } + return self.commitChatOfflineDataRemoval(gatewayID: gatewayID) } // Full reset retires every open handle before removing SQLite sidecars, @@ -777,33 +840,72 @@ final class NodeAppModel { for cache in self.chatTranscriptCachesByGatewayID.values { await cache.retire() } - if let directoryURL = Self.chatTranscriptCacheDirectoryURL() { - try? FileManager.default.removeItem(at: directoryURL) + do { + try self.clientDatabases?.close() + self.clientDatabases = nil + try Self.removeAllChatDatabaseFiles() + } catch { + clientDatabaseLogger.error( + "full database reset failed: \(error.localizedDescription, privacy: .public)") + return false } self.chatTranscriptCachesByGatewayID.removeAll() + self.quarantinedChatOfflineGatewayIDs.removeAll() self.chatTranscriptCacheGeneration &+= 1 + return true } /// Debug launch reset runs before Chat can create a cache actor, so direct /// file removal preserves the launch flag's synchronous startup contract. func purgeChatTranscriptCacheBeforeStartup() { - guard let directoryURL = Self.chatTranscriptCacheDirectoryURL() else { return } - try? FileManager.default.removeItem(at: directoryURL) + do { + try self.clientDatabases?.close() + self.clientDatabases = nil + try Self.removeAllChatDatabaseFiles() + } catch { + clientDatabaseLogger.error( + "startup database reset failed: \(error.localizedDescription, privacy: .public)") + return + } self.chatTranscriptCachesByGatewayID.removeAll() + self.quarantinedChatOfflineGatewayIDs.removeAll() self.chatTranscriptCacheGeneration &+= 1 } - private static func chatTranscriptCacheDirectoryURL() -> URL? { + static func chatDatabaseDirectoryURL() -> URL? { + try? OpenClawNodeStorage.appSupportDir() + .appendingPathComponent("databases", isDirectory: true) + } + + private static func legacyChatDatabaseDirectoryURL() -> URL? { try? OpenClawNodeStorage.appSupportDir() .appendingPathComponent("chat-cache", isDirectory: true) } - static func chatTranscriptCacheDatabaseURL(gatewayID: String) -> URL? { - let digest = SHA256.hash(data: Data(gatewayID.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return Self.chatTranscriptCacheDirectoryURL()? - .appendingPathComponent("\(digest).sqlite", isDirectory: false) + private static func removeAllChatDatabaseFiles() throws { + if let directoryURL = chatDatabaseDirectoryURL() { + try OpenClawClientDatabases.removeDatabaseFiles(in: directoryURL) + } + if let legacyDirectoryURL = Self.legacyChatDatabaseDirectoryURL(), + FileManager.default.fileExists(atPath: legacyDirectoryURL.path) + { + try FileManager.default.removeItem(at: legacyDirectoryURL) + } + } + + private static func makeClientDatabases() -> OpenClawClientDatabases? { + guard let directoryURL = chatDatabaseDirectoryURL() else { return nil } + let legacyDirectories = Self.legacyChatDatabaseDirectoryURL().map { [$0] } ?? [] + do { + return try OpenClawClientDatabases( + directoryURL: directoryURL, + legacyDirectoryURLs: legacyDirectories, + registeredGatewayIDs: Set(GatewaySettingsStore.loadGatewayRegistry().entries.map(\.stableID))) + } catch { + clientDatabaseLogger.error( + "client databases unavailable: \(error.localizedDescription, privacy: .public)") + return nil + } } private(set) var activeGatewayConnectConfig: GatewayConnectConfig? @@ -1088,6 +1190,26 @@ final class NodeAppModel { break case .active: self.isBackgrounded = false + if self.clientDatabases == nil { + // Recovery must run even after forgetting the final gateway; + // no chat facade may remain to initialize the container. + self.clientDatabases = Self.makeClientDatabases() + } + let registeredGatewayIDs = Set( + GatewaySettingsStore.loadGatewayRegistry().entries.map(\.stableID)) + self.clientDatabases?.resolvePendingGatewayRemovals( + registeredGatewayIDs: registeredGatewayIDs) + if let clientDatabases = self.clientDatabases { + let recoveredGatewayIDs = self.quarantinedChatOfflineGatewayIDs.filter { + !clientDatabases.hasPendingGatewayRemoval(gatewayID: $0) + } + if !recoveredGatewayIDs.isEmpty { + self.quarantinedChatOfflineGatewayIDs.subtract(recoveredGatewayIDs) + self.chatTranscriptCacheGeneration &+= 1 + } + } + self.clientDatabases?.retryLegacyImport( + registeredGatewayIDs: registeredGatewayIDs) self.endBackgroundConnectionGracePeriod(reason: "scene_foreground") self.clearBackgroundReconnectSuppression(reason: "scene_foreground") var shouldStartGatewayHealthMonitor = self.operatorConnected diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift index 69810af8a4b..08c9ef48331 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift @@ -3,14 +3,15 @@ import OpenClawKit enum GatewayOnboardingReset { @MainActor + @discardableResult static func prepareForBootstrapPairing( appModel: NodeAppModel, instanceId: String, gatewayStableID: String, disconnectGateway: Bool = true, - defaults: UserDefaults = .standard) async + defaults: UserDefaults = .standard) async -> Bool { - _ = await self.prepare( + await self.prepare( appModel: appModel, instanceId: instanceId, gatewayStableID: gatewayStableID, @@ -61,7 +62,10 @@ enum GatewayOnboardingReset { disconnectGateway: Bool, defaults: UserDefaults) async -> Bool { - await appModel.purgeChatTranscriptCache(gatewayID: gatewayStableID) + guard await appModel.purgeChatTranscriptCache(gatewayID: gatewayStableID) else { + appModel.gatewayStatusText = "Could not remove offline data for this gateway" + return false + } return self.preparePairingState( appModel: appModel, instanceId: instanceId, diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 4fe4b13dfdf..aaed424622a 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -988,11 +988,17 @@ extension OnboardingWizardView { let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) self.gatewayCredentialFieldStableID = setupAuth.targetStableID if setupAuth.hasBootstrapToken { - await GatewayOnboardingReset.prepareForBootstrapPairing( + guard await GatewayOnboardingReset.prepareForBootstrapPairing( appModel: self.appModel, instanceId: GatewaySettingsStore.currentInstanceID(), gatewayStableID: setupAuth.targetStableID, disconnectGateway: disconnectExistingGatewayForBootstrap) + else { + let message = "Could not safely replace the gateway's offline data. Try again." + self.connectMessage = message + self.statusLine = message + return + } } self.gatewayToken = setupAuth.token self.gatewayPassword = setupAuth.password diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index bfc69d49678..7165c692367 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -2051,7 +2051,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn } } - @Test @MainActor func `forget gateway clears matching share relay only`() { + @Test @MainActor func `forget gateway clears matching share relay only`() async { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } let priorRelay = ShareGatewayRelaySettings.loadConfig() @@ -2073,18 +2073,18 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn defer { appModel.disconnectGateway() } let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - controller.forgetGateway(stableID: stableID) + await controller.forgetGateway(stableID: stableID) #expect(ShareGatewayRelaySettings.loadConfig() == nil) } - @Test @MainActor func `forget manual gateway clears matching legacy auto connect defaults`() { + @Test @MainActor func `forget manual gateway clears matching legacy auto connect defaults`() async { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } let host = "forgotten.example.com" let port = 443 let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(host: host, port: port) - withUserDefaults([ + await withUserDefaults([ "gateway.manual.enabled": true, "gateway.manual.host": host, "gateway.manual.port": port, @@ -2094,7 +2094,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn defer { appModel.disconnectGateway() } let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - controller.forgetGateway(stableID: stableID) + await controller.forgetGateway(stableID: stableID) let defaults = UserDefaults.standard #expect(!defaults.bool(forKey: "gateway.manual.enabled")) @@ -2104,10 +2104,10 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn } } - @Test @MainActor func `forget gateway preserves legacy defaults for another manual gateway`() { + @Test @MainActor func `forget gateway preserves legacy defaults for another manual gateway`() async { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } - withUserDefaults([ + await withUserDefaults([ "gateway.manual.enabled": true, "gateway.manual.host": "kept.example.com", "gateway.manual.port": 443, @@ -2117,7 +2117,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn defer { appModel.disconnectGateway() } let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - controller.forgetGateway(stableID: "manual|forgotten.example.com|443") + await controller.forgetGateway(stableID: "manual|forgotten.example.com|443") let defaults = UserDefaults.standard #expect(defaults.bool(forKey: "gateway.manual.enabled")) @@ -2152,7 +2152,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn let identity = DeviceIdentityStore.loadOrCreate() defer { DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "node", gatewayID: connectedID) } - controller.forgetGateway(stableID: connectedID) + await controller.forgetGateway(stableID: connectedID) _ = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "node", @@ -2176,7 +2176,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn gatewayID: connectedID) == nil) } - @Test @MainActor func `forget selected gateway preserves a different live route`() throws { + @Test @MainActor func `forget selected gateway preserves a different live route`() async throws { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } let service = GatewaySettingsStore._testGatewayService @@ -2209,14 +2209,14 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn appModel.applyGatewayConnectConfig(connectedConfig) let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - controller.forgetGateway(stableID: selectedID) + await controller.forgetGateway(stableID: selectedID) #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: connectedConfig) == true) #expect(GatewaySettingsStore.activeGatewayEntry() == nil) #expect(GatewaySettingsStore.loadGatewayRegistry().entries.map(\.stableID) == [connectedID]) } - @Test @MainActor func `forget gateway clears only matching legacy discovery selectors`() { + @Test @MainActor func `forget gateway clears only matching legacy discovery selectors`() async { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } let service = GatewaySettingsStore._testGatewayService @@ -2241,7 +2241,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn _ = KeychainStore.saveString(forgottenID, service: service, account: preferredAccount) _ = KeychainStore.saveString(keptID, service: service, account: lastAccount) - withUserDefaults([ + await withUserDefaults([ "gateway.preferredStableID": forgottenID, "gateway.lastDiscoveredStableID": keptID, ]) { @@ -2249,7 +2249,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn defer { appModel.disconnectGateway() } let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - controller.forgetGateway(stableID: forgottenID) + await controller.forgetGateway(stableID: forgottenID) let defaults = UserDefaults.standard #expect(defaults.object(forKey: "gateway.preferredStableID") == nil) @@ -2288,7 +2288,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn await controller.connectManual(host: host, port: port, useTLS: true) #expect(controller.pendingTrustPrompt?.stableID == stableID) - controller.forgetGateway(stableID: stableID) + await controller.forgetGateway(stableID: stableID) await controller.acceptPendingTrustPrompt() #expect(controller.pendingTrustPrompt == nil) @@ -2315,7 +2315,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn await controller.connectManual(host: pendingHost, port: 443, useTLS: true) #expect(controller.pendingTrustPrompt?.stableID == pendingID) - controller.forgetGateway(stableID: "bonjour|unrelated-forgotten") + await controller.forgetGateway(stableID: "bonjour|unrelated-forgotten") #expect(controller.pendingTrustPrompt?.stableID == pendingID) } @@ -2343,7 +2343,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn await controller.connectManual(host: replacementHost, port: 443, useTLS: true) #expect(controller.pendingTrustPrompt?.stableID == replacementID) - controller.forgetGateway(stableID: connectedID) + await controller.forgetGateway(stableID: connectedID) await controller.acceptPendingTrustPrompt() let deadline = ContinuousClock().now.advanced(by: .seconds(3)) while appModel.activeGatewayConnectConfig?.effectiveStableID != replacementID, diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 3c17da2c3c4..43972a7c513 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -2451,12 +2451,13 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi let appModel = NodeAppModel(talkMode: talkMode) let barrier = TalkPreparationBarrier() let stableID = "talk-routing-restore-\(UUID().uuidString)" - let databaseURL = try #require(NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID)) + let databaseDirectoryURL = try #require(NodeAppModel.chatDatabaseDirectoryURL()) + let databases = try OpenClawClientDatabases(directoryURL: databaseDirectoryURL) let identity = try #require(OpenClawChatSessionRoutingIdentity( scope: "per-sender", mainSessionKey: "restored-main", defaultAgentID: "main")) - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: stableID) + let store = databases.store(gatewayID: stableID) await store.storeSessionRoutingIdentity(identity) await store.retire() appModel._test_setChatSessionRoutingRestoreHandler { @@ -2465,7 +2466,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi defer { barrier.release() appModel._test_setChatSessionRoutingRestoreHandler(nil) - OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL) + try? databases.removeGatewayData(gatewayID: stableID) appModel.voiceWake.stop() } @@ -2489,12 +2490,13 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi let appModel = NodeAppModel() let barrier = TalkPreparationBarrier() let stableID = "cancelled-routing-restore-\(UUID().uuidString)" - let databaseURL = try #require(NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID)) + let databaseDirectoryURL = try #require(NodeAppModel.chatDatabaseDirectoryURL()) + let databases = try OpenClawClientDatabases(directoryURL: databaseDirectoryURL) let identity = try #require(OpenClawChatSessionRoutingIdentity( scope: "per-sender", mainSessionKey: "stale-main", defaultAgentID: "main")) - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: stableID) + let store = databases.store(gatewayID: stableID) await store.storeSessionRoutingIdentity(identity) await store.retire() appModel._test_setConnectedGatewayID(stableID) @@ -2504,7 +2506,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi defer { barrier.release() appModel._test_setChatSessionRoutingRestoreHandler(nil) - OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL) + try? databases.removeGatewayData(gatewayID: stableID) appModel.voiceWake.stop() } diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 280bbddb862..4d468873826 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f7fca58180a3bae3a12878c2126de5a1e8faeafd7335e79ce92239efa5620148", + "originHash" : "edd9e4d33f7bfa6f12e839506792d9019b48928c713d214e670c97e2fb0ab53e", "pins" : [ { "identity" : "axorcist", @@ -28,6 +28,15 @@ "version" : "0.1.1" } }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" + } + }, { "identity" : "keyboardshortcuts", "kind" : "remoteSourceControl", diff --git a/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift b/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift index 2b3527d41a6..1f6493d1a2f 100644 --- a/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift +++ b/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift @@ -1,13 +1,8 @@ import Foundation import OpenClawChatUI -/// Builds the read-only offline chat transcript cache for the macOS app. -/// -/// The database lives in the per-user Application Support container -/// (`~/Library/Application Support/OpenClaw/chat-cache.sqlite`), matching the -/// existing OpenClaw app-support layout. macOS has no per-file Data Protection -/// classes (the iOS store applies `completeUntilFirstUserAuthentication`); -/// at-rest protection here is the per-user container permissions plus FileVault. +/// Builds the gateway-scoped facade over the two installation-wide databases +/// shared with iOS through OpenClawKit. enum MacChatTranscriptCache { struct Context { let store: OpenClawChatSQLiteTranscriptCache @@ -17,7 +12,8 @@ enum MacChatTranscriptCache { /// Every chat window for one gateway must share the same outbox actor. /// Otherwise a newly opened window can mistake another live window's /// claimed send for crash residue during its first recovery pass. - @MainActor private static var storesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:] + @MainActor private static var databasesByDirectory: [String: OpenClawClientDatabases] = [:] + @MainActor private static var storesByKey: [String: OpenClawChatSQLiteTranscriptCache] = [:] /// Stable identity of the gateway this app talks to, derivable offline /// (the cache pre-paints before any connection is up). Keys must not @@ -115,21 +111,59 @@ enum MacChatTranscriptCache { else { return nil } - let databaseURL = base.appendingPathComponent("OpenClaw/chat-cache.sqlite", isDirectory: false) + let root = base.appendingPathComponent("OpenClaw", isDirectory: true) + let databaseDirectoryURL = root.appendingPathComponent("databases", isDirectory: true) + guard let databases = self.databases( + directoryURL: databaseDirectoryURL, + legacyDirectoryURL: root) + else { return nil } return Context( - store: self.store(databaseURL: databaseURL, gatewayID: gatewayID), - routingIdentity: OpenClawChatSQLiteTranscriptCache.loadSessionRoutingIdentity( - databaseURL: databaseURL, - gatewayID: gatewayID)) + store: self.store(databases: databases, gatewayID: gatewayID), + routingIdentity: databases.loadSessionRoutingIdentity(gatewayID: gatewayID)) } @MainActor - static func store(databaseURL: URL, gatewayID: String) -> OpenClawChatSQLiteTranscriptCache { - if let store = storesByGatewayID[gatewayID] { + static func store(databaseDirectoryURL: URL, gatewayID: String) -> OpenClawChatSQLiteTranscriptCache? { + guard let databases = self.databases( + directoryURL: databaseDirectoryURL, + legacyDirectoryURL: databaseDirectoryURL) + else { return nil } + return self.store(databases: databases, gatewayID: gatewayID) + } + + @MainActor + private static func store( + databases: OpenClawClientDatabases, + gatewayID: String) -> OpenClawChatSQLiteTranscriptCache + { + let key = "\(databases.directoryURL.standardizedFileURL.path)|\(gatewayID)" + if let store = storesByKey[key] { return store } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID) - self.storesByGatewayID[gatewayID] = store + let store = databases.store(gatewayID: gatewayID) + self.storesByKey[key] = store return store } + + @MainActor + private static func databases( + directoryURL: URL, + legacyDirectoryURL: URL) -> OpenClawClientDatabases? + { + let key = directoryURL.standardizedFileURL.path + if let databases = databasesByDirectory[key] { + // macOS does not yet have a complete multi-gateway pairing + // registry. Unknown ownership imports all non-forgotten legacy + // gateways and never commits a cancelable staged removal. + databases.resolvePendingGatewayRemovals() + databases.retryLegacyImport() + return databases + } + guard let databases = try? OpenClawClientDatabases( + directoryURL: directoryURL, + legacyDirectoryURLs: [legacyDirectoryURL]) + else { return nil } + self.databasesByDirectory[key] = databases + return databases + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift index 054fb53887e..6011b5b5b11 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ChatTranscriptCacheIdentityTests.swift @@ -17,11 +17,16 @@ struct ChatTranscriptCacheIdentityTests { } @Test @MainActor func `windows share one outbox owner per gateway`() { - let databaseURL = FileManager.default.temporaryDirectory - .appendingPathComponent("openclaw-cache-owner-\(UUID().uuidString).sqlite") + let databaseDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-cache-owner-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: databaseDirectoryURL) } let gatewayID = "gw-shared-\(UUID().uuidString)" - let first = MacChatTranscriptCache.store(databaseURL: databaseURL, gatewayID: gatewayID) - let second = MacChatTranscriptCache.store(databaseURL: databaseURL, gatewayID: gatewayID) + let first = MacChatTranscriptCache.store( + databaseDirectoryURL: databaseDirectoryURL, + gatewayID: gatewayID) + let second = MacChatTranscriptCache.store( + databaseDirectoryURL: databaseDirectoryURL, + gatewayID: gatewayID) #expect(first === second) } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 75e6cac9279..4b9c9e077ff 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -1095,7 +1095,13 @@ struct GatewayProcessManagerTests { manager._testSetLastObservedGatewayPID(nil) } - #expect(await manager.waitForGatewayReady(timeout: 0.5)) + let stateDir = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-gateway-pid-refresh-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: stateDir) } + let ready = await DeviceIdentityStore.withStateDirectory(stateDir) { + await manager.waitForGatewayReady(timeout: 0.5) + } + #expect(ready) #expect(manager._testControlChannelRefreshForces().last == true) #expect(manager.status == .running(details: "pid 4343")) diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index f300fc76603..f96148c0354 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -3679,7 +3679,8 @@ struct OnboardingAISetupTests { } model.submitManualKey() - for _ in 0 ..< 200 { + // The full suite can starve MainActor work; wait for state instead of a one-second budget. + for _ in 0 ..< 2000 { if !model.manualTesting, model.waitingForPendingActivationDeadline { break } diff --git a/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift b/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift index 7307dc68786..9d92eb8ea9c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift @@ -56,23 +56,27 @@ import Testing #expect(entry == dist.path) } - @Test func `log locator picks newest log file`() throws { + @MainActor + @Test func `log locator picks newest log file`() async throws { let fm = FileManager() - let dir = URL(fileURLWithPath: "/tmp/openclaw", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + let dir = fm.temporaryDirectory + .appendingPathComponent("openclaw-log-locator-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: dir) } - let older = dir.appendingPathComponent("openclaw-old-\(UUID().uuidString).log") - let newer = dir.appendingPathComponent("openclaw-new-\(UUID().uuidString).log") - fm.createFile(atPath: older.path, contents: Data("old".utf8)) - fm.createFile(atPath: newer.path, contents: Data("new".utf8)) - try fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -100)], ofItemAtPath: older.path) - try fm.setAttributes([.modificationDate: Date()], ofItemAtPath: newer.path) + try await TestIsolation.withEnvValues(["OPENCLAW_LOG_DIR": dir.path]) { + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let older = dir.appendingPathComponent("openclaw-old-\(UUID().uuidString).log") + let newer = dir.appendingPathComponent("openclaw-new-\(UUID().uuidString).log") + fm.createFile(atPath: older.path, contents: Data("old".utf8)) + fm.createFile(atPath: newer.path, contents: Data("new".utf8)) + try fm.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -100)], + ofItemAtPath: older.path) + try fm.setAttributes([.modificationDate: Date()], ofItemAtPath: newer.path) - let best = LogLocator.bestLogFile() - #expect(best?.lastPathComponent == newer.lastPathComponent) - - try? fm.removeItem(at: older) - try? fm.removeItem(at: newer) + let best = LogLocator.bestLogFile() + #expect(best?.lastPathComponent == newer.lastPathComponent) + } } @Test func `gateway entrypoint nil when missing`() { diff --git a/apps/shared/OpenClawKit/Package.swift b/apps/shared/OpenClawKit/Package.swift index f043efea84e..92ce8a67f11 100644 --- a/apps/shared/OpenClawKit/Package.swift +++ b/apps/shared/OpenClawKit/Package.swift @@ -21,6 +21,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"), + .package(url: "https://github.com/groue/GRDB.swift.git", exact: "7.11.1"), .package(url: "https://github.com/mgriebling/SwiftMath", exact: "1.7.3"), .package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.8.0"), ], @@ -59,6 +60,7 @@ let package = Package( dependencies: [ "OpenClawKit", "OpenClawProtocol", + .product(name: "GRDB", package: "GRDB.swift"), .product(name: "Markdown", package: "swift-markdown"), .product(name: "SwiftMath", package: "SwiftMath"), ], @@ -68,7 +70,12 @@ let package = Package( ]), .testTarget( name: "OpenClawKitTests", - dependencies: ["OpenClawKit", "OpenClawChatUI", "OpenClawProtocol"], + dependencies: [ + "OpenClawKit", + "OpenClawChatUI", + "OpenClawProtocol", + .product(name: "GRDB", package: "GRDB.swift"), + ], path: "Tests/OpenClawKitTests", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift index 03cd0991233..df748a0d26f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift @@ -1,10 +1,7 @@ import Foundation +import GRDB import OpenClawKit import OSLog -import SQLite3 -#if os(iOS) -import UIKit -#endif private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatTranscriptCache") @@ -51,7 +48,7 @@ private final class OutboxChangeHub: @unchecked Sendable { } /// Canonical gateway evidence must beat a user cancellation synchronously; -/// actor hops would leave a window where an already-delivered row is scrubbed. +/// actor hops would leave a window where an already-delivered row is hidden. private final class CanonicalMessageProofHub: @unchecked Sendable { private static let maxKeys = 512 private let lock = NSLock() @@ -70,9 +67,6 @@ private final class CanonicalMessageProofHub: @unchecked Sendable { self.lock.unlock() } - /// Serializes the final cancellation decision and SQLite commit with - /// synchronous canonical observation. Evidence recorded before this lock - /// wins; evidence after it observes a completed cancellation. func lockProofDecision(for key: String) -> Bool { self.lock.lock() return self.keys.contains(key) @@ -83,174 +77,71 @@ private final class CanonicalMessageProofHub: @unchecked Sendable { } } -/// SQLite-backed transcript cache for one gateway identity. Owners should use -/// one database file per gateway so reset can physically remove that gateway's -/// cached transcript bytes without disturbing other paired gateways; queries -/// are additionally scoped by `gatewayID` as a defensive belt. -/// -/// Transcript rows are disposable, but the command outbox is persistent user -/// state. Schema upgrades migrate the shared database; unknown or corrupt -/// existing schemas fail closed without deleting queued commands. +/// Gateway-scoped facade over the installation-wide cache and client-state +/// databases. The facade owns no SQLite connection; every gateway store from +/// one container shares exactly one GRDB queue per database file. public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, OpenClawChatCanonicalTranscriptMerging, OpenClawChatCommandOutbox { - /// Bounds keep the cache small: enough for a recently-used session picker - /// and a full first screen of transcript, not a durable archive. public static let maxCachedSessions = 50 public static let maxCachedTranscripts = 50 public static let maxCachedMessagesPerSession = 200 - /// Outbox bounds: refuse enqueue beyond these per-gateway budgets, and - /// expire queued commands instead of sending them after two days offline. public static let maxQueuedCommands = 50 public static let maxAttachmentBytesPerCommand = 40_000_000 public static let maxQueuedAttachmentBytes = 50_000_000 public static let outboxCommandMaxAge: TimeInterval = 48 * 60 * 60 - /// Machine-readable `lastError` set by the staleness gate. public static let outboxExpiredError = "expired" public static let outboxUnconfirmedError = "delivery_unconfirmed" public static let outboxUnknownTargetError = "delivery_target_unknown" public static let outboxChangedTargetError = "delivery_target_changed" - // v2 adds the durable outbox; v3 adds delivery ownership; v4 binds - // replay to the main-routing contract; v5 persists that verified identity; - // v6 keeps bounded attachment bytes inside the same durable command owner. - static let schemaVersion: Int32 = 6 - private static let createOutboxTableSQL = """ - CREATE TABLE IF NOT EXISTS outbox_commands( - id INTEGER PRIMARY KEY AUTOINCREMENT, - client_uuid TEXT NOT NULL UNIQUE, - gateway_id TEXT NOT NULL, - session_key TEXT NOT NULL, - delivery_session_key TEXT NOT NULL DEFAULT '', - routing_contract TEXT NOT NULL DEFAULT '', - agent_id TEXT NOT NULL DEFAULT '', - text TEXT NOT NULL, - attachments TEXT NOT NULL DEFAULT '[]', - attachment_bytes INTEGER NOT NULL DEFAULT 0, - thinking TEXT NOT NULL DEFAULT '', - created_at REAL NOT NULL, - status TEXT NOT NULL, - retry_count INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '' - ) - """ - private static let createTranscriptTableSQL = """ - CREATE TABLE IF NOT EXISTS cached_transcripts( - gateway_id TEXT NOT NULL, - session_key TEXT NOT NULL, - agent_id TEXT NOT NULL DEFAULT '', - payload TEXT NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY(gateway_id, session_key, agent_id) - ) - """ - private static let createRoutingIdentityTableSQL = """ - CREATE TABLE IF NOT EXISTS 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 - ) - """ - /// Owns the raw sqlite handle so it closes on release without needing an - /// isolated actor deinit (OpaquePointer is not Sendable). - private final class Connection: @unchecked Sendable { - let raw: OpaquePointer - - init(raw: OpaquePointer) { - self.raw = raw - } - - deinit { - sqlite3_close_v2(self.raw) - } - } - - private let databaseURL: URL + private let databases: OpenClawClientDatabases public nonisolated let gatewayID: String - private var db: Connection? private var isRetired = false private var hasRecoveredInterruptedSends = false - /// Process-lifetime tombstones reject stale transcript snapshots from an - /// overlapping view model after its queued bubble was canceled durably. - private var canceledMessageKeysBySession: [String: [String]] = [:] private nonisolated let outboxChangeHub = OutboxChangeHub() private nonisolated let canonicalMessageProofHub = CanonicalMessageProofHub() - /// Existing database failures preserve persistent outbox bytes and make - /// this store a no-op; an explicit owner purge remains the recovery path. - private var isBroken = false - public init(databaseURL: URL, gatewayID: String) { - self.databaseURL = databaseURL + init(databases: OpenClawClientDatabases, gatewayID: String) { + self.databases = databases self.gatewayID = gatewayID } - /// Startup-only synchronous read for UI owners that must seed routing - /// before constructing a view model. Runtime writes stay actor-isolated. - public nonisolated static func loadSessionRoutingIdentity( - databaseURL: URL, - gatewayID: String) -> OpenClawChatSessionRoutingIdentity? - { - var db: OpaquePointer? - guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, - let db - else { - sqlite3_close_v2(db) - return nil - } - defer { sqlite3_close_v2(db) } - var statement: OpaquePointer? - let sql = """ - SELECT scope, main_session_key, default_agent_id - FROM gateway_routing_identity WHERE gateway_id = ?1 - """ - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } - defer { sqlite3_finalize(statement) } - let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - guard sqlite3_bind_text(statement, 1, gatewayID, -1, transient) == SQLITE_OK, - sqlite3_step(statement) == SQLITE_ROW, - let scope = sqlite3_column_text(statement, 0), - let mainSessionKey = sqlite3_column_text(statement, 1), - let defaultAgentID = sqlite3_column_text(statement, 2) - else { return nil } - return OpenClawChatSessionRoutingIdentity( - scope: String(cString: scope), - mainSessionKey: String(cString: mainSessionKey), - defaultAgentID: String(cString: defaultAgentID)) - } - - /// Startup-only cleanup, before any cache actor can own an open handle. - public static func removeDatabaseFiles(at databaseURL: URL) { - let fm = FileManager.default - try? fm.removeItem(at: databaseURL) - for suffix in ["-wal", "-shm", "-journal"] { - try? fm.removeItem(at: URL(fileURLWithPath: databaseURL.path + suffix)) - } - } - - // MARK: - OpenClawChatTranscriptCache + // MARK: - Gateway cache public func loadSessions() async -> [OpenClawChatSessionEntry] { guard !self.isRetired else { return [] } - guard let db = await handle() else { return [] } - guard let payload = selectPayload( - db, - sql: "SELECT payload FROM cached_sessions WHERE gateway_id = ?1", - bindings: [gatewayID]) - else { + let gatewayID = self.gatewayID + do { + return try await self.databases.cacheQueue.write { db in + let rows = try Row.fetchAll( + db, + sql: """ + SELECT payload_json FROM cached_sessions + WHERE gateway_id = ? ORDER BY position + """, + arguments: [gatewayID]) + do { + return try rows.map { row in + let payload: String = row["payload_json"] + return try JSONDecoder().decode(OpenClawChatSessionEntry.self, from: Data(payload.utf8)) + } + } catch { + cacheLogger.error( + "gateway session cache decode failed: \(error.localizedDescription, privacy: .public)") + // Decode and cleanup share one transaction so a newer + // snapshot can never land between the failed read and delete. + try db.execute( + sql: "DELETE FROM cached_sessions WHERE gateway_id = ?", + arguments: [gatewayID]) + return [] + } + } + } catch { + cacheLogger.error("gateway session cache read failed: \(error.localizedDescription, privacy: .public)") return [] } - guard let decoded = try? JSONDecoder().decode( - [OpenClawChatSessionEntry].self, - from: Data(payload.utf8)) - else { - // Decode mismatch means a stale/foreign shape: drop the row silently. - self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID]) - return [] - } - return decoded } public func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] { @@ -259,121 +150,72 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, public func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage] { guard !self.isRetired else { return [] } - guard let db = await handle() else { return [] } - return self.readTranscript(db, sessionKey: sessionKey, agentID: agentID) - } - - private func readTranscript( - _ db: OpaquePointer, - sessionKey: String, - agentID: String?) -> [OpenClawChatMessage] - { let normalizedAgentID = Self.normalizedAgentID(agentID) - guard let payload = selectPayload( - db, - sql: """ - SELECT payload FROM cached_transcripts - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 - """, - bindings: [gatewayID, sessionKey, normalizedAgentID]) - else { + let gatewayID = self.gatewayID + do { + return try await self.databases.cacheQueue.write { db in + let rows = try Row.fetchAll( + db, + sql: """ + SELECT payload_json FROM cached_messages + WHERE gateway_id = ? AND session_key = ? AND agent_id = ? + ORDER BY position + """, + arguments: [gatewayID, sessionKey, normalizedAgentID]) + do { + return try rows.map { row in + let payload: String = row["payload_json"] + return try JSONDecoder().decode(OpenClawChatMessage.self, from: Data(payload.utf8)) + } + } catch { + cacheLogger.error( + "gateway transcript cache decode failed: \(error.localizedDescription, privacy: .public)") + // Keep the failed read and partition cleanup atomic; an + // overlapping history write must survive this recovery. + try db.execute( + sql: """ + DELETE FROM cached_transcripts + WHERE gateway_id = ? AND session_key = ? AND agent_id = ? + """, + arguments: [gatewayID, sessionKey, normalizedAgentID]) + return [] + } + } + } catch { + cacheLogger.error("gateway transcript cache read failed: \(error.localizedDescription, privacy: .public)") return [] } - guard let decoded = try? JSONDecoder().decode( - [OpenClawChatMessage].self, - from: Data(payload.utf8)) - else { - self.execute( - db, - sql: """ - DELETE FROM cached_transcripts - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 - """, - bindings: [self.gatewayID, sessionKey, normalizedAgentID]) - return [] - } - return decoded } public func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async { guard !self.isRetired else { return } - guard let db = await handle() else { return } let bounded = Self.boundedSessions(sessions) - guard !bounded.isEmpty else { - self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID]) - return + let gatewayID = self.gatewayID + do { + let encoded = try bounded.map(Self.encodeJSON) + try await self.databases.cacheQueue.write { db in + try db.execute( + sql: "DELETE FROM cached_sessions WHERE gateway_id = ?", + arguments: [gatewayID]) + for (position, pair) in zip(bounded, encoded).enumerated() { + try db.execute( + sql: """ + INSERT INTO cached_sessions( + gateway_id, session_key, position, updated_at, payload_json + ) VALUES (?, ?, ?, ?, ?) + """, + arguments: [ + gatewayID, + pair.0.key, + position, + pair.0.updatedAt ?? 0, + pair.1, + ]) + } + } + } catch { + cacheLogger.error("gateway session cache write failed: \(error.localizedDescription, privacy: .public)") } - guard let payload = Self.encodeJSON(bounded) else { return } - self.execute( - db, - sql: """ - INSERT OR REPLACE INTO cached_sessions(gateway_id, payload, updated_at) - VALUES (?1, ?2, ?3) - """, - bindings: [self.gatewayID, payload, Date().timeIntervalSince1970]) - } - - public func loadSessionRoutingIdentity() async -> OpenClawChatSessionRoutingIdentity? { - guard !self.isRetired, let db = await handle() else { return nil } - var statement: OpaquePointer? - let sql = """ - SELECT scope, main_session_key, default_agent_id - FROM gateway_routing_identity WHERE gateway_id = ?1 - """ - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: [self.gatewayID]), - sqlite3_step(statement) == SQLITE_ROW, - let scope = sqlite3_column_text(statement, 0), - let mainSessionKey = sqlite3_column_text(statement, 1), - let defaultAgentID = sqlite3_column_text(statement, 2) - else { return nil } - return OpenClawChatSessionRoutingIdentity( - scope: String(cString: scope), - mainSessionKey: String(cString: mainSessionKey), - defaultAgentID: String(cString: defaultAgentID)) - } - - public func storeSessionRoutingIdentity(_ identity: OpenClawChatSessionRoutingIdentity) async { - guard !self.isRetired, let db = await handle() else { return } - self.execute( - db, - sql: """ - INSERT OR REPLACE INTO gateway_routing_identity( - gateway_id, scope, main_session_key, default_agent_id, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5) - """, - bindings: [ - self.gatewayID, - identity.scope, - identity.mainSessionKey, - identity.defaultAgentID, - Date().timeIntervalSince1970, - ]) - } - - public func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async { - await self.storeTranscript(sessionKey: sessionKey, agentID: nil, messages: messages) - } - - public func storeTranscript( - sessionKey: String, - agentID: String?, - messages: [OpenClawChatMessage]) async - { - await self.writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: messages) - } - - public func storeCanonicalTranscript( - sessionKey: String, - messages: [OpenClawChatMessage], - canonicalMessageIdempotencyKeys: Set) async - { - await self.storeCanonicalTranscript( - sessionKey: sessionKey, - agentID: nil, - messages: messages, - canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys) } public func storeCanonicalTranscript( @@ -383,13 +225,17 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, canonicalMessageIdempotencyKeys: Set) async { self.observeCanonicalMessageIdempotencyKeys(canonicalMessageIdempotencyKeys) - if !canonicalMessageIdempotencyKeys.isEmpty, - var canceledKeys = canceledMessageKeysBySession[sessionKey] - { - canceledKeys.removeAll(where: canonicalMessageIdempotencyKeys.contains) - self.canceledMessageKeysBySession[sessionKey] = canceledKeys.isEmpty ? nil : canceledKeys + // Every local user echo uses the `:user` convention. Requiring + // explicit gateway proof also rejects a canceled echo captured by an + // overlapping history reconciliation before its state row was deleted. + let canonicalOnly = messages.filter { message in + guard message.role.lowercased() == "user", + let key = message.idempotencyKey, + key.hasSuffix(":user") + else { return true } + return canonicalMessageIdempotencyKeys.contains(key) } - await self.writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: messages) + await self.writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: canonicalOnly) } public func mergeCanonicalTranscriptMessage( @@ -399,167 +245,257 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, canonicalMessageIdempotencyKey: String) async { self.observeCanonicalMessageIdempotencyKeys([canonicalMessageIdempotencyKey]) - guard !self.isRetired, let db = await handle() else { return } - - if var canceledKeys = canceledMessageKeysBySession[sessionKey] { - canceledKeys.removeAll(where: { $0 == canonicalMessageIdempotencyKey }) - self.canceledMessageKeysBySession[sessionKey] = canceledKeys.isEmpty ? nil : canceledKeys + guard !self.isRetired else { return } + let normalizedAgentID = Self.normalizedAgentID(agentID) + let gatewayID = self.gatewayID + do { + try await self.databases.cacheQueue.write { db in + let rows = try Row.fetchAll( + db, + sql: """ + SELECT payload_json FROM cached_messages + WHERE gateway_id = ? AND session_key = ? AND agent_id = ? + ORDER BY position + """, + arguments: [gatewayID, sessionKey, normalizedAgentID]) + var cached = rows.compactMap { row -> OpenClawChatMessage? in + let payload: String = row["payload_json"] + return try? JSONDecoder().decode(OpenClawChatMessage.self, from: Data(payload.utf8)) + } + if let index = cached.firstIndex(where: { + $0.idempotencyKey?.trimmingCharacters(in: .whitespacesAndNewlines) == + canonicalMessageIdempotencyKey + }) { + cached[index] = message + } else if let timestamp = message.timestamp, + let index = cached.firstIndex(where: { + ($0.timestamp ?? .greatestFiniteMagnitude) > timestamp + }) + { + cached.insert(message, at: index) + } else { + cached.append(message) + } + try Self.replaceTranscript( + db, + gatewayID: gatewayID, + sessionKey: sessionKey, + agentID: normalizedAgentID, + messages: cached) + } + } catch { + cacheLogger.error("gateway transcript cache merge failed: \(error.localizedDescription, privacy: .public)") } - - // Keep the read, merge, and write in one actor turn. macOS shares this - // cache across windows, so a caller-side read/modify/write can erase a - // newer snapshot written by another view model. - var cached = self.readTranscript(db, sessionKey: sessionKey, agentID: agentID) - if let index = cached.firstIndex(where: { - $0.idempotencyKey?.trimmingCharacters(in: .whitespacesAndNewlines) == - canonicalMessageIdempotencyKey - }) { - cached[index] = message - } else if let timestamp = message.timestamp, - let index = cached.firstIndex(where: { ($0.timestamp ?? .greatestFiniteMagnitude) > timestamp }) - { - cached.insert(message, at: index) - } else { - cached.append(message) - } - self.writeTranscript(db, sessionKey: sessionKey, agentID: agentID, messages: cached) } public nonisolated func observeCanonicalMessageIdempotencyKeys(_ keys: Set) { self.canonicalMessageProofHub.observe(keys) } + public func loadSessionRoutingIdentity() async -> OpenClawChatSessionRoutingIdentity? { + guard !self.isRetired else { return nil } + return self.databases.loadSessionRoutingIdentity(gatewayID: self.gatewayID) + } + + public func storeSessionRoutingIdentity(_ identity: OpenClawChatSessionRoutingIdentity) async { + guard !self.isRetired else { return } + let gatewayID = self.gatewayID + do { + try await self.databases.stateQueue.write { db in + 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 + """, + arguments: [ + gatewayID, + identity.scope, + identity.mainSessionKey, + identity.defaultAgentID, + Date().timeIntervalSince1970, + ]) + } + } catch { + cacheLogger.error("client state routing write failed: \(error.localizedDescription, privacy: .public)") + } + } + + public func retire() async { + self.isRetired = true + self.outboxChangeHub.finish() + } +} + +extension OpenClawChatSQLiteTranscriptCache { private func writeTranscript( sessionKey: String, agentID: String?, messages: [OpenClawChatMessage]) async { guard !self.isRetired else { return } - guard let db = await handle() else { return } - self.writeTranscript(db, sessionKey: sessionKey, agentID: agentID, messages: messages) + let normalizedAgentID = Self.normalizedAgentID(agentID) + let gatewayID = self.gatewayID + do { + try await self.databases.cacheQueue.write { db in + try Self.replaceTranscript( + db, + gatewayID: gatewayID, + sessionKey: sessionKey, + agentID: normalizedAgentID, + messages: messages) + } + } catch { + cacheLogger.error("gateway transcript cache write failed: \(error.localizedDescription, privacy: .public)") + } } - private func writeTranscript( - _ db: OpaquePointer, + private nonisolated static func replaceTranscript( + _ db: Database, + gatewayID: String, sessionKey: String, - agentID: String?, - messages: [OpenClawChatMessage]) + agentID: String, + messages: [OpenClawChatMessage]) throws { - let normalizedAgentID = Self.normalizedAgentID(agentID) - let canceledKeys = self.canceledMessageKeysBySession[sessionKey] ?? [] - let bounded = Self.cacheableMessages(messages).filter { message in - guard let key = message.idempotencyKey else { return true } - return !canceledKeys.contains(key) + let bounded = self.cacheableMessages(messages) + let encoded = try bounded.map(self.encodeJSON) + try db.execute( + sql: """ + DELETE FROM cached_transcripts + WHERE gateway_id = ? AND session_key = ? AND agent_id = ? + """, + arguments: [gatewayID, sessionKey, agentID]) + guard !bounded.isEmpty else { return } + try db.execute( + sql: """ + INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, updated_at) + VALUES (?, ?, ?, ?) + """, + arguments: [gatewayID, sessionKey, agentID, Date().timeIntervalSince1970]) + for (position, pair) in zip(bounded, encoded).enumerated() { + try db.execute( + sql: """ + INSERT INTO cached_messages( + gateway_id, session_key, agent_id, position, + timestamp_ms, idempotency_key, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + gatewayID, + sessionKey, + agentID, + position, + pair.0.timestamp, + pair.0.idempotencyKey, + pair.1, + ]) } - guard !bounded.isEmpty else { - // An emptied live transcript must also empty the cache, or the next - // cold open would ghost-paint messages the gateway no longer has. - self.execute( - db, + let stale = try Row.fetchAll( + db, + sql: """ + SELECT session_key, agent_id FROM cached_transcripts + WHERE gateway_id = ? + ORDER BY updated_at DESC, rowid DESC + LIMIT -1 OFFSET \(self.maxCachedTranscripts) + """, + arguments: [gatewayID]) + for row in stale { + let staleSessionKey: String = row["session_key"] + let staleAgentID: String = row["agent_id"] + try db.execute( sql: """ DELETE FROM cached_transcripts - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 + WHERE gateway_id = ? AND session_key = ? AND agent_id = ? """, - bindings: [self.gatewayID, sessionKey, normalizedAgentID]) - return + arguments: [gatewayID, staleSessionKey, staleAgentID]) } - guard let payload = Self.encodeJSON(bounded) else { return } - self.execute( - db, - sql: """ - INSERT OR REPLACE INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) - """, - bindings: [self.gatewayID, sessionKey, normalizedAgentID, payload, Date().timeIntervalSince1970]) - // rowid tie-breaks equal timestamps: INSERT OR REPLACE mints a fresh - // rowid, so the most recently written transcript always survives. - self.execute( - db, - sql: """ - DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND rowid NOT IN ( - SELECT rowid FROM cached_transcripts WHERE gateway_id = ?1 - ORDER BY updated_at DESC, rowid DESC LIMIT \(Self.maxCachedTranscripts) - ) - """, - bindings: [self.gatewayID]) - } - - public func retire() async { - // A queued write then either finishes before retirement or becomes a - // no-op. Closing the handle lets the owner delete the whole cache file. - self.isRetired = true - self.db = nil - self.outboxChangeHub.finish() } } extension OpenClawChatSQLiteTranscriptCache { - // MARK: - OpenClawChatCommandOutbox + // MARK: - Client-state outbox public nonisolated func changes() -> AsyncStream { self.outboxChangeHub.stream() } public func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool { - guard let attachmentByteCount = Self.attachmentByteCount(command.attachments), - Self.canEnqueueAttachmentBytes( - commandBytes: attachmentByteCount, - queuedBytes: 0), - let attachments = Self.encodeJSON(command.attachments), - !self.isRetired, - let db = await handle(), - sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK + guard !self.isRetired, + let attachmentByteCount = Self.attachmentByteCount(command.attachments), + Self.canEnqueueAttachmentBytes(commandBytes: attachmentByteCount, queuedBytes: 0) else { return false } - var committed = false - defer { - if !committed { - sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + let gatewayID = self.gatewayID + do { + return try await self.databases.stateQueue.write { db in + let count = try Int.fetchOne( + db, + sql: "SELECT COUNT(*) FROM outbox_commands WHERE gateway_id = ?", + arguments: [gatewayID]) ?? 0 + let queuedBytes = try Int.fetchOne( + db, + sql: """ + SELECT COALESCE(SUM(attachment_bytes), 0) + FROM outbox_commands WHERE gateway_id = ? + """, + arguments: [gatewayID]) ?? 0 + guard count < Self.maxQueuedCommands, + Self.canEnqueueAttachmentBytes( + commandBytes: attachmentByteCount, + queuedBytes: queuedBytes) + else { return false } + try db.execute( + sql: """ + INSERT 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: [ + gatewayID, + command.id, + command.sessionKey, + command.deliverySessionKey, + command.routingContract ?? "", + command.agentID ?? "", + command.text, + command.thinking, + command.createdAt, + command.status.rawValue, + command.retryCount, + command.lastError ?? "", + attachmentByteCount, + ]) + 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: [ + gatewayID, + command.id, + position, + attachment.type, + attachment.mimeType, + attachment.fileName, + attachment.data, + attachment.durationSeconds, + ]) + } + return true } + } catch { + cacheLogger.error("outbox enqueue failed: \(error.localizedDescription, privacy: .public)") + return false } - guard let count = selectInt( - db, - sql: "SELECT COUNT(*) FROM outbox_commands WHERE gateway_id = ?1", - bindings: [gatewayID]), - let queuedAttachmentBytes = selectInt( - db, - sql: "SELECT COALESCE(SUM(attachment_bytes), 0) FROM outbox_commands WHERE gateway_id = ?1", - bindings: [gatewayID]) - else { return false } - // Count all statuses: failed and unconfirmed rows still own their bytes - // until canonical history or explicit deletion releases them. - guard count < Self.maxQueuedCommands, - Self.canEnqueueAttachmentBytes( - commandBytes: attachmentByteCount, - queuedBytes: queuedAttachmentBytes), - self.execute( - db, - sql: """ - INSERT INTO outbox_commands( - client_uuid, gateway_id, session_key, delivery_session_key, routing_contract, - agent_id, text, attachments, attachment_bytes, thinking, created_at, status, - retry_count, last_error - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) - """, - bindings: [ - command.id, - self.gatewayID, - command.sessionKey, - command.deliverySessionKey, - command.routingContract ?? "", - command.agentID ?? "", - command.text, - attachments, - attachmentByteCount, - command.thinking, - command.createdAt, - command.status.rawValue, - command.retryCount, - command.lastError ?? "", - ]), - sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK - else { return false } - committed = true - return true } public func loadCommands() async -> [OpenClawChatOutboxCommand] { @@ -567,91 +503,91 @@ extension OpenClawChatSQLiteTranscriptCache { } public func loadCommandsIfAvailable() async -> [OpenClawChatOutboxCommand]? { - guard !self.isRetired, let db = await handle() else { return nil } - guard self.applyOutboxStaleness(db) else { return nil } - return self.readCommands(db) + guard !self.isRetired else { return nil } + let gatewayID = self.gatewayID + do { + return try await self.databases.stateQueue.write { db in + try Self.applyOutboxStaleness(db, gatewayID: gatewayID) + return try Self.readCommands(db, gatewayID: gatewayID) + } + } catch { + cacheLogger.error("outbox read failed: \(error.localizedDescription, privacy: .public)") + return nil + } } @discardableResult public func recoverInterruptedSends() async -> Bool { guard !self.isRetired else { return false } if self.hasRecoveredInterruptedSends { return true } - guard let db = await handle() else { return false } - let recovered = self.execute( - db, - sql: """ - UPDATE outbox_commands SET status = 'failed', last_error = ?2 - WHERE gateway_id = ?1 AND status = 'sending' - """, - bindings: [self.gatewayID, Self.outboxUnconfirmedError]) - if recovered { + let gatewayID = self.gatewayID + do { + try await self.databases.stateQueue.write { db in + try db.execute( + sql: """ + UPDATE outbox_commands SET status = 'failed', last_error = ? + WHERE gateway_id = ? AND status = 'sending' + """, + arguments: [Self.outboxUnconfirmedError, gatewayID]) + } self.hasRecoveredInterruptedSends = true + return true + } catch { + return false } - return recovered } public func claimNextCommand() async -> OpenClawChatOutboxCommand? { - guard !self.isRetired, let db = await handle() else { return nil } - guard self.execute(db, sql: "BEGIN IMMEDIATE", bindings: []) else { return nil } - var committed = false - defer { - if !committed { - _ = self.execute(db, sql: "ROLLBACK", bindings: []) + guard !self.isRetired else { return nil } + let gatewayID = self.gatewayID + do { + return try await self.databases.stateQueue.write { db in + try Self.applyOutboxStaleness(db, gatewayID: gatewayID) + let active = try Int.fetchOne( + db, + sql: """ + SELECT COUNT(*) FROM outbox_commands + WHERE gateway_id = ? AND status = 'sending' + """, + arguments: [gatewayID]) ?? 0 + guard active == 0, + let row = try Row.fetchOne( + db, + sql: """ + SELECT * FROM outbox_commands + WHERE gateway_id = ? AND status = 'queued' + ORDER BY created_at, enqueue_sequence LIMIT 1 + """, + arguments: [gatewayID]) + else { return nil } + let id: String = row["client_uuid"] + try db.execute( + sql: """ + UPDATE outbox_commands SET status = 'sending' + WHERE gateway_id = ? AND client_uuid = ? AND status = 'queued' + """, + arguments: [gatewayID, id]) + guard db.changesCount > 0 else { return nil } + var command = try Self.command(from: row, in: db, gatewayID: gatewayID) + command.status = .sending + return command } - } - guard self.applyOutboxStaleness(db) else { return nil } - guard let activeClaimCount = selectInt( - db, - sql: "SELECT COUNT(*) FROM outbox_commands WHERE gateway_id = ?1 AND status = 'sending'", - bindings: [gatewayID]) - else { return nil } - let hasActiveClaim = activeClaimCount > 0 - guard !hasActiveClaim else { - committed = self.execute(db, sql: "COMMIT", bindings: []) + } catch { + cacheLogger.error("outbox claim failed: \(error.localizedDescription, privacy: .public)") return nil } - guard let commands = readCommands(db) else { return nil } - guard var next = commands.first(where: { $0.status == .queued }) else { - committed = self.execute(db, sql: "COMMIT", bindings: []) - return nil - } - let updated = self.execute( - db, - sql: """ - UPDATE outbox_commands SET status = 'sending' - WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'queued' - """, - bindings: [self.gatewayID, next.id]) && sqlite3_changes(db) > 0 - guard updated else { return nil } - committed = self.execute(db, sql: "COMMIT", bindings: []) - guard committed else { return nil } - next.status = .sending - return next } public func markCommandQueued(id: String, retryCount: Int, lastError: String?) async { - await self.updateCommandStatus(id: id, status: "queued", retryCount: retryCount, lastError: lastError) + await self.updateCommandStatus(id: id, status: .queued, retryCount: retryCount, lastError: lastError) } public func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult { - guard !self.isRetired, let db = await handle() else { - self.hasRecoveredInterruptedSends = false - return .unavailable - } - let updated = self.execute( - db, - sql: """ - UPDATE outbox_commands SET status = 'awaiting_confirmation', retry_count = 0, last_error = '' - WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'sending' - """, - bindings: [self.gatewayID, id]) - guard updated else { - // The claimed row may still be `sending`; a later healthy pass - // must fail it closed within this store lifetime. - self.hasRecoveredInterruptedSends = false - return .unavailable - } - return sqlite3_changes(db) > 0 ? .updated : .missing + await self.transitionClaimedCommand( + id: id, + status: .awaitingConfirmation, + retryCount: 0, + lastError: nil) } public func markCommandFailedIfPresent( @@ -659,24 +595,11 @@ extension OpenClawChatSQLiteTranscriptCache { retryCount: Int, lastError: String?) async -> OpenClawChatOutboxUpdateResult { - guard !self.isRetired, let db = await handle() else { - self.hasRecoveredInterruptedSends = false - return .unavailable - } - let updated = self.execute( - db, - sql: """ - UPDATE outbox_commands SET status = 'failed', retry_count = ?3, last_error = ?4 - WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'sending' - """, - bindings: [self.gatewayID, id, retryCount, lastError ?? ""]) - guard updated else { - // The caller must stop this flush pass until storage is healthy; - // otherwise a durable `sending` row can block the FIFO silently. - self.hasRecoveredInterruptedSends = false - return .unavailable - } - return sqlite3_changes(db) > 0 ? .updated : .missing + await self.transitionClaimedCommand( + id: id, + status: .failed, + retryCount: retryCount, + lastError: lastError) } public func markCommandRetriedIfPresent( @@ -685,7 +608,7 @@ extension OpenClawChatSQLiteTranscriptCache { deliverySessionKey: String, routingContract: String) async -> OpenClawChatOutboxUpdateResult { - guard !self.isRetired, let db = await handle() else { return .unavailable } + guard !self.isRetired else { return .unavailable } let normalizedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" let normalizedDeliverySessionKey = deliverySessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedRoutingContract = routingContract.trimmingCharacters(in: .whitespacesAndNewlines) @@ -695,194 +618,237 @@ extension OpenClawChatSQLiteTranscriptCache { !normalizedDeliverySessionKey.isEmpty, !normalizedRoutingContract.isEmpty else { return .unavailable } - guard self.execute(db, sql: "BEGIN IMMEDIATE", bindings: []) else { return .unavailable } - var committed = false - defer { - if !committed { - _ = self.execute(db, sql: "ROLLBACK", bindings: []) + let gatewayID = self.gatewayID + do { + return try await self.databases.stateQueue.write { db in + try db.execute( + sql: """ + UPDATE outbox_commands + SET status = 'queued', retry_count = 0, last_error = '', created_at = ?, + agent_id = ?, delivery_session_key = ?, routing_contract = ? + WHERE gateway_id = ? AND client_uuid = ? AND status = 'failed' + """, + arguments: [ + Date().timeIntervalSince1970, + normalizedAgentID, + normalizedDeliverySessionKey, + normalizedRoutingContract, + gatewayID, + id, + ]) + return db.changesCount > 0 ? .updated : .missing } - } - let previousTarget = self.lookupOutboxTarget(db, id: id) - let previousSessionKey: String - let previousAgentID: String - switch previousTarget { - case let .value(sessionKey, agentID): - previousSessionKey = sessionKey - previousAgentID = agentID - case .missing: - committed = self.execute(db, sql: "COMMIT", bindings: []) - return committed ? .missing : .unavailable - case .unavailable: + } catch { return .unavailable } - guard self.execute( - db, - sql: """ - UPDATE outbox_commands - SET status = 'queued', retry_count = 0, last_error = '', created_at = ?3, - agent_id = ?4, delivery_session_key = ?5, routing_contract = ?6 - WHERE gateway_id = ?1 AND client_uuid = ?2 AND status = 'failed' - """, - bindings: [ - self.gatewayID, - id, - Date().timeIntervalSince1970, - normalizedAgentID, - normalizedDeliverySessionKey, - normalizedRoutingContract, - ]) - else { return .unavailable } - guard sqlite3_changes(db) > 0 else { - committed = self.execute(db, sql: "COMMIT", bindings: []) - return committed ? .missing : .unavailable - } - // Retargeting adopts a new cache owner. Remove the optimistic row - // from the previous partition before the command becomes sendable. - if Self.normalizedAgentID(previousAgentID) != normalizedAgentID, - !self.removeCachedMessage( - db, - sessionKey: previousSessionKey, - agentID: Self.transcriptCacheAgentID( - sessionKey: previousSessionKey, - agentID: previousAgentID), - idempotencyKey: "\(id):user") - { - return .unavailable - } - committed = self.execute(db, sql: "COMMIT", bindings: []) - return committed ? .updated : .unavailable } public func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult { - guard !self.isRetired, let db = await handle() else { return .unavailable } - guard self.execute(db, sql: "BEGIN IMMEDIATE", bindings: []) else { return .unavailable } - var committed = false - defer { - if !committed { - _ = self.execute(db, sql: "ROLLBACK", bindings: []) - } - } + guard !self.isRetired else { return .unavailable } let messageKey = "\(id):user" - let targetLookup = self.lookupOutboxTarget(db, id: id) - let sessionKey: String - let agentID: String - switch targetLookup { - case let .value(foundSessionKey, foundAgentID): - sessionKey = foundSessionKey - agentID = foundAgentID - case .missing: - let isProven = self.canonicalMessageProofHub.lockProofDecision(for: messageKey) - defer { self.canonicalMessageProofHub.unlockProofDecision() } - committed = self.execute(db, sql: "COMMIT", bindings: []) - guard committed else { return .unavailable } - return isProven ? .confirmed : .missing - case .unavailable: + let gatewayID = self.gatewayID + let proofHub = self.canonicalMessageProofHub + do { + let (deleted, isProven) = try await self.databases.stateQueue.writeWithoutTransaction { db in + let isProven = proofHub.lockProofDecision(for: messageKey) + defer { proofHub.unlockProofDecision() } + var deleted = false + try db.inTransaction(.immediate) { + try db.execute( + sql: """ + DELETE FROM outbox_commands + WHERE gateway_id = ? AND client_uuid = ? AND status IN ('queued', 'failed') + """, + arguments: [gatewayID, id]) + deleted = db.changesCount > 0 + return .commit + } + return (deleted, isProven) + } + guard deleted else { return isProven ? .confirmed : .missing } + if isProven { + self.outboxChangeHub.yield(.confirmed(id: id)) + return .confirmed + } + self.outboxChangeHub.yield(.canceled(id: id)) + return .updated + } catch { return .unavailable } - guard self.execute( - db, - sql: """ - DELETE FROM outbox_commands - WHERE gateway_id = ?1 AND client_uuid = ?2 AND status IN ('queued', 'failed') - """, - bindings: [self.gatewayID, id]) - else { return .unavailable } - guard sqlite3_changes(db) > 0 else { return .unavailable } - let result: OpenClawChatOutboxUpdateResult - do { - let isProven = self.canonicalMessageProofHub.lockProofDecision(for: messageKey) - defer { self.canonicalMessageProofHub.unlockProofDecision() } - if isProven { - committed = self.execute(db, sql: "COMMIT", bindings: []) - result = committed ? .confirmed : .unavailable - } else { - guard self.removeCachedMessage( - db, - sessionKey: sessionKey, - agentID: Self.transcriptCacheAgentID( - sessionKey: sessionKey, - agentID: agentID), - idempotencyKey: messageKey) - else { return .unavailable } - committed = self.execute(db, sql: "COMMIT", bindings: []) - result = committed ? .updated : .unavailable - } - } - if result == .confirmed { - self.emitOutboxChange(.confirmed(id: id)) - return .confirmed - } - guard result == .updated else { return .unavailable } - Self.rememberMessageKey( - messageKey, - sessionKey: sessionKey, - storage: &self.canceledMessageKeysBySession) - self.emitOutboxChange(.canceled(id: id)) - return .updated } public func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult { - guard !self.isRetired, let db = await handle() else { return .unavailable } - guard self.execute( - db, - sql: "DELETE FROM outbox_commands WHERE gateway_id = ?1 AND client_uuid = ?2", - bindings: [self.gatewayID, id]) - else { return .unavailable } - guard sqlite3_changes(db) > 0 else { return .missing } - self.emitOutboxChange(.confirmed(id: id)) - return .updated - } - - private func emitOutboxChange(_ change: OpenClawChatOutboxChange) { - self.outboxChangeHub.yield(change) - } - - private func updateCommandStatus(id: String, status: String, retryCount: Int, lastError: String?) async { - guard !self.isRetired, let db = await handle() else { - self.hasRecoveredInterruptedSends = false - return + guard !self.isRetired else { return .unavailable } + let gatewayID = self.gatewayID + do { + let deleted = try await self.databases.stateQueue.write { db in + try db.execute( + sql: "DELETE FROM outbox_commands WHERE gateway_id = ? AND client_uuid = ?", + arguments: [gatewayID, id]) + return db.changesCount > 0 + } + guard deleted else { return .missing } + self.outboxChangeHub.yield(.confirmed(id: id)) + return .updated + } catch { + return .unavailable } - let updated = self.execute( - db, - sql: """ - UPDATE outbox_commands SET status = ?3, retry_count = ?4, last_error = ?5 - WHERE gateway_id = ?1 AND client_uuid = ?2 - """, - bindings: [self.gatewayID, id, status, retryCount, lastError ?? ""]) - if !updated { - self.hasRecoveredInterruptedSends = false - } - } - - private func applyOutboxStaleness(_ db: OpaquePointer) -> Bool { - // Old queued work is no longer safe to send automatically. Likewise, - // an acknowledged row that never appeared in canonical history needs - // an explicit user decision rather than a potentially duplicate replay. - self.execute( - db, - sql: """ - UPDATE outbox_commands - SET status = 'failed', - last_error = CASE WHEN status = 'awaiting_confirmation' THEN ?4 ELSE ?3 END - WHERE gateway_id = ?1 - AND status IN ('queued', 'awaiting_confirmation') - AND created_at < ?2 - """, - bindings: [ - self.gatewayID, - Date().timeIntervalSince1970 - Self.outboxCommandMaxAge, - Self.outboxExpiredError, - Self.outboxUnconfirmedError, - ]) } } extension OpenClawChatSQLiteTranscriptCache { - // MARK: - Cached shapes + private func transitionClaimedCommand( + id: String, + status: OpenClawChatOutboxCommand.Status, + retryCount: Int, + lastError: String?) async -> OpenClawChatOutboxUpdateResult + { + guard !self.isRetired else { + self.hasRecoveredInterruptedSends = false + return .unavailable + } + let gatewayID = self.gatewayID + do { + let updated = try await self.databases.stateQueue.write { db in + try db.execute( + sql: """ + UPDATE outbox_commands + SET status = ?, retry_count = ?, last_error = ? + WHERE gateway_id = ? AND client_uuid = ? AND status = 'sending' + """, + arguments: [ + status.rawValue, + retryCount, + lastError ?? "", + gatewayID, + id, + ]) + return db.changesCount > 0 + } + return updated ? .updated : .missing + } catch { + self.hasRecoveredInterruptedSends = false + return .unavailable + } + } - /// Cache v1 strips attachments and ordinary tool arguments. Patch calls - /// retain only one bounded patch envelope so their diffs survive cold paint; - /// bounded applied diff details remain the other supported diff source. + private func updateCommandStatus( + id: String, + status: OpenClawChatOutboxCommand.Status, + retryCount: Int, + lastError: String?) async + { + guard !self.isRetired else { + self.hasRecoveredInterruptedSends = false + return + } + let gatewayID = self.gatewayID + do { + try await self.databases.stateQueue.write { db in + try db.execute( + sql: """ + UPDATE outbox_commands + SET status = ?, retry_count = ?, last_error = ? + WHERE gateway_id = ? AND client_uuid = ? + """, + arguments: [ + status.rawValue, + retryCount, + lastError ?? "", + gatewayID, + id, + ]) + } + } catch { + self.hasRecoveredInterruptedSends = false + } + } + + private nonisolated static func applyOutboxStaleness( + _ db: Database, + gatewayID: String) throws + { + try db.execute( + sql: """ + UPDATE outbox_commands + SET status = 'failed', + last_error = CASE + WHEN status = 'awaiting_confirmation' THEN ? ELSE ? + END + WHERE gateway_id = ? + AND status IN ('queued', 'awaiting_confirmation') + AND created_at < ? + """, + arguments: [ + self.outboxUnconfirmedError, + self.outboxExpiredError, + gatewayID, + Date().timeIntervalSince1970 - self.outboxCommandMaxAge, + ]) + } + + private nonisolated static func readCommands( + _ db: Database, + gatewayID: String) throws -> [OpenClawChatOutboxCommand] + { + let rows = try Row.fetchAll( + db, + sql: """ + SELECT * FROM outbox_commands WHERE gateway_id = ? + ORDER BY created_at, enqueue_sequence + """, + arguments: [gatewayID]) + return try rows.map { try self.command(from: $0, in: db, gatewayID: gatewayID) } + } + + private nonisolated static func command( + from row: Row, + in db: Database, + gatewayID: String) throws -> OpenClawChatOutboxCommand + { + let id: String = row["client_uuid"] + let attachmentRows = try Row.fetchAll( + db, + sql: """ + SELECT type, mime_type, file_name, payload, duration_seconds + FROM outbox_attachments + WHERE gateway_id = ? AND command_id = ? ORDER BY position + """, + arguments: [gatewayID, id]) + let attachments = attachmentRows.map { attachmentRow in + OpenClawChatOutboxAttachment( + type: attachmentRow["type"], + mimeType: attachmentRow["mime_type"], + fileName: attachmentRow["file_name"], + data: attachmentRow["payload"], + durationSeconds: attachmentRow["duration_seconds"]) + } + let statusRaw: String = row["status"] + guard let status = OpenClawChatOutboxCommand.Status(rawValue: statusRaw) else { + throw DatabaseError(message: "unknown outbox status") + } + let lastError: String = row["last_error"] + return OpenClawChatOutboxCommand( + id: id, + sessionKey: row["session_key"], + deliverySessionKey: row["delivery_session_key"], + routingContract: row["routing_contract"], + agentID: row["agent_id"], + text: row["text"], + attachments: attachments, + thinking: row["thinking"], + createdAt: row["created_at"], + status: status, + retryCount: row["retry_count"], + lastError: lastError.isEmpty ? nil : lastError) + } +} + +extension OpenClawChatSQLiteTranscriptCache { + // MARK: - Portable cache record shaping + + /// Cache format v1 stores one JSON document per session/message row. Large + /// attachment bodies and ordinary tool arguments are never cache data. static func cacheableMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { messages.suffix(self.maxCachedMessagesPerSession).map { message in OpenClawChatMessage( @@ -900,7 +866,6 @@ extension OpenClawChatSQLiteTranscriptCache { content: nil, id: item.id, name: item.name, - // Patch envelopes are the sole argument exception because details are not always emitted. arguments: self.cacheablePatchArguments(item), details: self.cacheableDetails(item.details), isError: item.isError) @@ -974,18 +939,6 @@ extension OpenClawChatSQLiteTranscriptCache { agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" } - private static func transcriptCacheAgentID(sessionKey: String, agentID: String) -> String { - let parts = sessionKey - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: ":", omittingEmptySubsequences: false) - // Canonical agent keys already own their cache partition. Aliases - // need the separate agent dimension to prevent cross-agent repaint. - if parts.count >= 3, parts[0].lowercased() == "agent", !parts[1].isEmpty, !parts[2].isEmpty { - return "" - } - return self.normalizedAgentID(agentID) - } - private static func attachmentByteCount(_ attachments: [OpenClawChatOutboxAttachment]) -> Int? { var total = 0 for attachment in attachments { @@ -1004,582 +957,13 @@ extension OpenClawChatSQLiteTranscriptCache { return queuedBytes <= self.maxQueuedAttachmentBytes - commandBytes } - private static func encodeJSON(_ value: some Encodable) -> String? { - guard let data = try? JSONEncoder().encode(value) else { return nil } - return String(bytes: data, encoding: .utf8) - } - - private static func rememberMessageKey( - _ key: String, - sessionKey: String, - storage: inout [String: [String]]) - { - var keys = storage[sessionKey] ?? [] - keys.removeAll(where: { $0 == key }) - keys.append(key) - if keys.count > Self.maxCachedMessagesPerSession { - keys.removeFirst(keys.count - Self.maxCachedMessagesPerSession) + private static func encodeJSON(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(value) + guard let result = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) } - storage[sessionKey] = keys - if storage.count > Self.maxCachedTranscripts, - let evictedSession = storage.keys.first(where: { $0 != sessionKey }) - { - storage.removeValue(forKey: evictedSession) - } - } - - /// Runs inside the outbox cancellation transaction, so a process exit can - /// never leave a deleted command behind as an ordinary cached sent row. - private func removeCachedMessage( - _ db: OpaquePointer, - sessionKey: String, - agentID: String, - idempotencyKey: String) -> Bool - { - let transcriptLookup = self.lookupPayload( - db, - sql: """ - SELECT payload FROM cached_transcripts - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 - """, - bindings: [self.gatewayID, sessionKey, agentID]) - let payload: String - switch transcriptLookup { - case let .value(value): - payload = value - case .missing: - return true - case .unavailable: - return false - } - guard let decoded = try? JSONDecoder().decode( - [OpenClawChatMessage].self, - from: Data(payload.utf8)) - else { - return self.execute( - db, - sql: """ - DELETE FROM cached_transcripts - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 - """, - bindings: [self.gatewayID, sessionKey, agentID]) - } - let filtered = decoded.filter { $0.idempotencyKey != idempotencyKey } - guard filtered.count != decoded.count else { return true } - guard !filtered.isEmpty else { - return self.execute( - db, - sql: """ - DELETE FROM cached_transcripts - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 - """, - bindings: [self.gatewayID, sessionKey, agentID]) - } - guard let filteredPayload = Self.encodeJSON(filtered) else { return false } - return self.execute( - db, - sql: """ - UPDATE cached_transcripts SET payload = ?4, updated_at = ?5 - WHERE gateway_id = ?1 AND session_key = ?2 AND agent_id = ?3 - """, - bindings: [self.gatewayID, sessionKey, agentID, filteredPayload, Date().timeIntervalSince1970]) - } - - // MARK: - Connection lifecycle - - private func handle() async -> OpaquePointer? { - guard !self.isRetired else { return nil } - if let db { return db.raw } - if self.isBroken { return nil } - #if os(iOS) - // Complete protection intentionally makes the cache unavailable while - // locked. Treat that as a temporary miss, never as corruption. - guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil } - #endif - let databaseExisted = FileManager.default.fileExists(atPath: self.databaseURL.path) - if let opened = openConnection() { - db = Connection(raw: opened) - return opened - } - #if os(iOS) - guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil } - #endif - if databaseExisted { - // The shared file may contain unsent user text. Preserve it for a - // future compatible build or explicit owner purge instead of - // turning a cache repair into silent outbox data loss. - cacheLogger.error("chat offline store unavailable; preserving existing database") - self.isBroken = true - return nil - } - // A failed first create cannot contain user state; remove the partial - // file and retry once so transient bootstrap failures self-heal. - self.removeDatabaseFiles() - if let reopened = openConnection() { - db = Connection(raw: reopened) - return reopened - } - cacheLogger.error("chat transcript cache unavailable; continuing without offline cache") - self.isBroken = true - return nil - } - - #if os(iOS) - private func isProtectedDataAvailable() async -> Bool { - await MainActor.run { UIApplication.shared.isProtectedDataAvailable } - } - #endif - - private func openConnection() -> OpaquePointer? { - let fm = FileManager.default - try? fm.createDirectory( - at: self.databaseURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - var opened: OpaquePointer? - var flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX - #if os(iOS) - // Apply Complete protection through the SQLite VFS so auxiliary files - // receive the same class as the main transcript database. - flags |= SQLITE_OPEN_FILEPROTECTION_COMPLETE - #endif - guard sqlite3_open_v2(self.databaseURL.path, &opened, flags, nil) == SQLITE_OK, let opened else { - sqlite3_close_v2(opened) - return nil - } - guard let version = readUserVersion(opened) else { - sqlite3_close_v2(opened) - return nil - } - if version == 0 { - guard self.createSchema(opened) else { - sqlite3_close_v2(opened) - return nil - } - } else if version == 1 { - guard self.migrateSchemaFromV1(opened) else { - sqlite3_close_v2(opened) - return nil - } - } else if version == 2 { - guard self.migrateSchemaFromV2(opened) else { - sqlite3_close_v2(opened) - return nil - } - } else if version == 3 { - guard self.migrateSchemaFromV3(opened) else { - sqlite3_close_v2(opened) - return nil - } - } else if version == 4 { - guard self.migrateSchemaFromV4(opened) else { - sqlite3_close_v2(opened) - return nil - } - } else if version == 5 { - guard self.migrateSchemaFromV5(opened) else { - sqlite3_close_v2(opened) - return nil - } - } else if version != Self.schemaVersion { - // Unknown schemas may contain outbox rows from a newer build. - // The caller preserves the file and fails closed. - sqlite3_close_v2(opened) - return nil - } - #if os(iOS) - // Upgrade a database created by an older build to the stricter class. - try? fm.setAttributes( - [.protectionKey: FileProtectionType.complete], - ofItemAtPath: self.databaseURL.path) - #endif - return opened - } - - private func readUserVersion(_ db: OpaquePointer) -> Int32? { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK else { - return nil - } - defer { sqlite3_finalize(statement) } - guard sqlite3_step(statement) == SQLITE_ROW else { return nil } - return sqlite3_column_int(statement, 0) - } - - private func createSchema(_ db: OpaquePointer) -> Bool { - let statements = [ - """ - CREATE TABLE IF NOT EXISTS cached_sessions( - gateway_id TEXT NOT NULL PRIMARY KEY, - payload TEXT NOT NULL, - updated_at REAL NOT NULL - ) - """, - Self.createTranscriptTableSQL, - Self.createOutboxTableSQL, - Self.createRoutingIdentityTableSQL, - "PRAGMA user_version = \(Self.schemaVersion)", - ] - for sql in statements { - guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return false } - } - return true - } - - private func migrateSchemaFromV1(_ db: OpaquePointer) -> Bool { - guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } - var committed = false - defer { - if !committed { - sqlite3_exec(db, "ROLLBACK", nil, nil, nil) - } - } - guard self.migrateTranscriptTableToV3(db), - sqlite3_exec(db, Self.createOutboxTableSQL, nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK - else { - return false - } - committed = true - return true - } - - private func migrateSchemaFromV2(_ db: OpaquePointer) -> Bool { - guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } - var committed = false - defer { - if !committed { - sqlite3_exec(db, "ROLLBACK", nil, nil, nil) - } - } - // A v2 non-canonical alias has no durable agent owner. Park it for - // explicit retry; the current default agent may have changed. Agent - // keys already carry enough ownership to migrate without replay risk. - guard self.migrateTranscriptTableToV3(db), - sqlite3_exec( - db, - "ALTER TABLE outbox_commands ADD COLUMN agent_id TEXT NOT NULL DEFAULT ''", - nil, - nil, - nil) == SQLITE_OK, - sqlite3_exec( - db, - "ALTER TABLE outbox_commands ADD COLUMN delivery_session_key TEXT NOT NULL DEFAULT ''", - nil, - nil, - nil) == SQLITE_OK, - sqlite3_exec( - db, - "ALTER TABLE outbox_commands ADD COLUMN routing_contract TEXT NOT NULL DEFAULT ''", - nil, - nil, - nil) == SQLITE_OK, - self.addV6AttachmentColumns(db), - self.execute( - db, - sql: """ - UPDATE outbox_commands - SET status = 'failed', - last_error = CASE - WHEN status IN ('sending', 'awaiting_confirmation') OR last_error = ?2 - THEN ?2 - ELSE ?1 - END, - agent_id = '', - delivery_session_key = '', routing_contract = '' - """, - bindings: [Self.outboxUnknownTargetError, Self.outboxUnconfirmedError]), - sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK - else { - return false - } - committed = true - return true - } - - private func migrateSchemaFromV3(_ db: OpaquePointer) -> Bool { - guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } - var committed = false - defer { - if !committed { - sqlite3_exec(db, "ROLLBACK", nil, nil, nil) - } - } - guard sqlite3_exec( - db, - "ALTER TABLE outbox_commands ADD COLUMN routing_contract TEXT NOT NULL DEFAULT ''", - nil, - nil, - nil) == SQLITE_OK, - self.addV6AttachmentColumns(db), - self.execute( - db, - sql: """ - UPDATE outbox_commands - SET status = 'failed', - last_error = CASE - WHEN status IN ('sending', 'awaiting_confirmation') OR last_error = ?2 - THEN ?2 - ELSE ?1 - END, - agent_id = '', - delivery_session_key = '', routing_contract = '' - """, - bindings: [Self.outboxUnknownTargetError, Self.outboxUnconfirmedError]), - sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK - else { return false } - committed = true - return true - } - - private func migrateSchemaFromV4(_ db: OpaquePointer) -> Bool { - guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } - var committed = false - defer { - if !committed { - sqlite3_exec(db, "ROLLBACK", nil, nil, nil) - } - } - guard sqlite3_exec(db, Self.createRoutingIdentityTableSQL, nil, nil, nil) == SQLITE_OK, - self.addV6AttachmentColumns(db), - sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK - else { return false } - committed = true - return true - } - - private func migrateSchemaFromV5(_ db: OpaquePointer) -> Bool { - guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return false } - var committed = false - defer { - if !committed { - sqlite3_exec(db, "ROLLBACK", nil, nil, nil) - } - } - guard self.addV6AttachmentColumns(db), - sqlite3_exec(db, "PRAGMA user_version = \(Self.schemaVersion)", nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK - else { return false } - committed = true - return true - } - - private func addV6AttachmentColumns(_ db: OpaquePointer) -> Bool { - sqlite3_exec( - db, - "ALTER TABLE outbox_commands ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]'", - nil, - nil, - nil) == SQLITE_OK && - sqlite3_exec( - db, - "ALTER TABLE outbox_commands ADD COLUMN attachment_bytes INTEGER NOT NULL DEFAULT 0", - nil, - nil, - nil) == SQLITE_OK - } - - private func migrateTranscriptTableToV3(_ db: OpaquePointer) -> Bool { - let hadAgentID = self.table(db, hasColumn: "agent_id", tableName: "cached_transcripts") - guard sqlite3_exec( - db, - "ALTER TABLE cached_transcripts RENAME TO cached_transcripts_pre_v3", - nil, - nil, - nil) == SQLITE_OK, - sqlite3_exec(db, Self.createTranscriptTableSQL, nil, nil, nil) == SQLITE_OK - else { return false } - let copySQL = hadAgentID - ? """ - INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at) - SELECT gateway_id, session_key, agent_id, payload, updated_at - FROM cached_transcripts_pre_v3 - """ - : """ - INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, payload, updated_at) - SELECT gateway_id, session_key, '', payload, updated_at - FROM cached_transcripts_pre_v3 - WHERE lower(trim(session_key)) GLOB 'agent:*:*' - """ - guard sqlite3_exec(db, copySQL, nil, nil, nil) == SQLITE_OK, - sqlite3_exec(db, "DROP TABLE cached_transcripts_pre_v3", nil, nil, nil) == SQLITE_OK - else { return false } - return true - } - - private func table(_ db: OpaquePointer, hasColumn columnName: String, tableName: String) -> Bool { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, "PRAGMA table_info(\(tableName))", -1, &statement, nil) == SQLITE_OK else { - return false - } - defer { sqlite3_finalize(statement) } - while sqlite3_step(statement) == SQLITE_ROW { - guard let name = sqlite3_column_text(statement, 1) else { continue } - if String(cString: name) == columnName { return true } - } - return false - } - - // MARK: - Statement helpers - - @discardableResult - private func execute(_ db: OpaquePointer, sql: String, bindings: [Any]) -> Bool { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { - cacheLogger.error("cache statement prepare failed") - return false - } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: bindings) else { return false } - return sqlite3_step(statement) == SQLITE_DONE - } - - private func selectInt(_ db: OpaquePointer, sql: String, bindings: [Any]) -> Int? { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: bindings) else { return nil } - guard sqlite3_step(statement) == SQLITE_ROW else { return nil } - return Int(sqlite3_column_int64(statement, 0)) - } - - private func readCommands(_ db: OpaquePointer) -> [OpenClawChatOutboxCommand]? { - var statement: OpaquePointer? - let sql = """ - SELECT client_uuid, session_key, delivery_session_key, routing_contract, agent_id, - text, attachments, thinking, created_at, status, retry_count, last_error - FROM outbox_commands WHERE gateway_id = ?1 - ORDER BY created_at ASC, id ASC - """ - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: [self.gatewayID]) else { return nil } - - var commands: [OpenClawChatOutboxCommand] = [] - var step = sqlite3_step(statement) - while step == SQLITE_ROW { - if let id = sqlite3_column_text(statement, 0), - let sessionKey = sqlite3_column_text(statement, 1), - let text = sqlite3_column_text(statement, 5) - { - let deliverySessionKey = sqlite3_column_text(statement, 2).map { String(cString: $0) } ?? "" - let routingContract = sqlite3_column_text(statement, 3).map { String(cString: $0) } - let agentID = sqlite3_column_text(statement, 4).map { String(cString: $0) } - let attachmentsPayload = sqlite3_column_text(statement, 6).map { String(cString: $0) } ?? "[]" - guard let attachments = try? JSONDecoder().decode( - [OpenClawChatOutboxAttachment].self, - from: Data(attachmentsPayload.utf8)) - else { return nil } - let thinking = sqlite3_column_text(statement, 7).map { String(cString: $0) } ?? "" - let statusRaw = sqlite3_column_text(statement, 9).map { String(cString: $0) } ?? "" - let lastError = sqlite3_column_text(statement, 11).map { String(cString: $0) } ?? "" - if let status = OpenClawChatOutboxCommand.Status(rawValue: statusRaw) { - commands.append( - OpenClawChatOutboxCommand( - id: String(cString: id), - sessionKey: String(cString: sessionKey), - deliverySessionKey: deliverySessionKey, - routingContract: routingContract, - agentID: agentID, - text: String(cString: text), - attachments: attachments, - thinking: thinking, - createdAt: sqlite3_column_double(statement, 8), - status: status, - retryCount: Int(sqlite3_column_int64(statement, 10)), - lastError: lastError.isEmpty ? nil : lastError)) - } - } - step = sqlite3_step(statement) - } - guard step == SQLITE_DONE else { return nil } - return commands - } - - private enum PayloadLookup { - case value(String) - case missing - case unavailable - } - - private enum OutboxTargetLookup { - case value(sessionKey: String, agentID: String) - case missing - case unavailable - } - - private func lookupOutboxTarget(_ db: OpaquePointer, id: String) -> OutboxTargetLookup { - var statement: OpaquePointer? - let sql = """ - SELECT session_key, agent_id FROM outbox_commands - WHERE gateway_id = ?1 AND client_uuid = ?2 AND status IN ('queued', 'failed') - """ - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return .unavailable } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: [self.gatewayID, id]) else { return .unavailable } - switch sqlite3_step(statement) { - case SQLITE_ROW: - guard let sessionKey = sqlite3_column_text(statement, 0) else { return .unavailable } - let agentID = sqlite3_column_text(statement, 1).map { String(cString: $0) } ?? "" - return .value(sessionKey: String(cString: sessionKey), agentID: agentID) - case SQLITE_DONE: - return .missing - default: - return .unavailable - } - } - - private func lookupPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> PayloadLookup { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return .unavailable } - defer { sqlite3_finalize(statement) } - guard self.bind(statement, bindings: bindings) else { return .unavailable } - switch sqlite3_step(statement) { - case SQLITE_ROW: - guard let text = sqlite3_column_text(statement, 0) else { return .unavailable } - return .value(String(cString: text)) - case SQLITE_DONE: - return .missing - default: - return .unavailable - } - } - - private func selectPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> String? { - guard case let .value(payload) = lookupPayload(db, sql: sql, bindings: bindings) else { - return nil - } - return payload - } - - private func bind(_ statement: OpaquePointer?, bindings: [Any]) -> Bool { - // SQLITE_TRANSIENT: sqlite copies the buffer before the Swift string dies. - let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - for (offset, value) in bindings.enumerated() { - let index = Int32(offset + 1) - let result: Int32 = switch value { - case let text as String: - sqlite3_bind_text(statement, index, text, -1, transient) - case let int as Int: - sqlite3_bind_int64(statement, index, Int64(int)) - case let real as Double: - sqlite3_bind_double(statement, index, real) - default: - SQLITE_MISUSE - } - guard result == SQLITE_OK else { return false } - } - return true - } - - private func removeDatabaseFiles() { - self.db = nil - Self.removeDatabaseFiles(at: self.databaseURL) + return result } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift index bea4a7973e0..65700d94c65 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift @@ -4,21 +4,15 @@ import Foundation /// /// The cache only pre-paints cold opens and covers offline browsing; connected /// reads always come from the gateway and replace cached content wholesale. -/// Implementations must scope all rows to a single gateway identity so -/// transcripts never leak across paired gateways. +/// Implementations must scope every row by gateway identity so one shared +/// installation database can safely serve all paired gateways. public protocol OpenClawChatTranscriptCache: Sendable { func loadSessions() async -> [OpenClawChatSessionEntry] func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] func loadTranscript(sessionKey: String, agentID: String?) async -> [OpenClawChatMessage] func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async - func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async - func storeTranscript(sessionKey: String, agentID: String?, messages: [OpenClawChatMessage]) async /// Canonical gateway rows can prove that an ambiguously delivered local /// command landed after cancellation and must override local suppression. - func storeCanonicalTranscript( - sessionKey: String, - messages: [OpenClawChatMessage], - canonicalMessageIdempotencyKeys: Set) async func storeCanonicalTranscript( sessionKey: String, agentID: String?, @@ -35,36 +29,6 @@ extension OpenClawChatTranscriptCache { return await self.loadTranscript(sessionKey: sessionKey) } - public func storeTranscript( - sessionKey: String, - agentID: String?, - messages: [OpenClawChatMessage]) async - { - guard agentID == nil else { return } - await self.storeTranscript(sessionKey: sessionKey, messages: messages) - } - - public func storeCanonicalTranscript( - sessionKey: String, - messages: [OpenClawChatMessage], - canonicalMessageIdempotencyKeys _: Set) async - { - await self.storeTranscript(sessionKey: sessionKey, messages: messages) - } - - public func storeCanonicalTranscript( - sessionKey: String, - agentID: String?, - messages: [OpenClawChatMessage], - canonicalMessageIdempotencyKeys: Set) async - { - guard agentID == nil else { return } - await self.storeCanonicalTranscript( - sessionKey: sessionKey, - messages: messages, - canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys) - } - public func observeCanonicalMessageIdempotencyKeys(_: Set) {} } @@ -190,9 +154,9 @@ public enum OpenClawChatOutboxChange: Equatable, Sendable { case confirmed(id: String) } -/// Durable offline outbox for chat commands, scoped to one gateway identity -/// exactly like the transcript cache. Implementations persist queued sends so -/// they survive app restarts and flush on reconnect. +/// Durable offline outbox for chat commands. Implementations expose one +/// gateway-scoped facade over installation-wide client state so queued sends +/// survive app restarts and flush on reconnect. public protocol OpenClawChatCommandOutbox: Sendable { /// Returns false when the row or attachment-byte budget is full, or /// storage is unavailable; callers surface that instead of dropping text. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift index 76985ef3a35..ce45ef79056 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift @@ -742,7 +742,7 @@ extension OpenClawChatViewModel { } // Durable outbox rows remain authoritative until canonical history // confirms their idempotency key. Keep their bubbles through lagging - // snapshots, including across app relaunches and session switches. + // snapshots; a cold open reconstructs them from client state. retainedMessageIDs.formUnion(self.outboxCommandIDsByMessageID.keys) var nextMessages = if preservingOptimisticLocalMessages { Self.reconcileRunRefreshMessages( @@ -794,8 +794,8 @@ extension OpenClawChatViewModel { // An empty post-send refresh is incomplete by contract: reconciliation // preserves the visible transcript, so preserve its last canonical cache too. if !preservingOptimisticLocalMessages || !incoming.isEmpty { - // Persist the reconciled transcript, including durable outbox - // rows retained while canonical history catches up. + // The cache store writes only gateway-derived rows. It filters + // locally retained outbox bubbles until history proves their keys. persistTranscriptToCache( sessionKey: request.session.key, agentID: request.session.agentID, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift index d223566b284..2e3a35e1ea5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift @@ -22,7 +22,7 @@ public enum OpenClawChatOutboxMessageState: Equatable, Sendable { } /// Durable offline command outbox. Sends made while the gateway is unhealthy -/// are persisted (per gateway, alongside the transcript cache) and flushed +/// are persisted in installation-wide client state and flushed /// strictly in createdAt order when health recovers. A gateway ACK only moves /// a row to awaiting-confirmation; canonical history owns durable completion. extension OpenClawChatViewModel { @@ -380,8 +380,8 @@ extension OpenClawChatViewModel { } /// Appends bubbles for commands in the current session, adopting rows - /// that already carry the command's user idempotency key (cache pre-paint - /// or an earlier restore), and refreshes their display states. + /// that already carry the command's user idempotency key from an earlier + /// restore, and refreshes their display states. private func presentOutboxCommands(_ commands: [OpenClawChatOutboxCommand]) { self.pruneOutboxMappings() guard !commands.isEmpty else { return } @@ -673,10 +673,9 @@ extension OpenClawChatViewModel { // handleSessionMessageEvent, and the post-drain history refresh. Run // tracking (typing indicator, streaming, timeouts) stays owned by // interactive performSend. chat.send ACK precedes durable user-turn - // persistence, so the cache splice keeps the acknowledged turn visible - // until canonical history carries its idempotency key. - await self.pendingCacheWriteTask?.value - await self.spliceSentCommandIntoCachedTranscript(command) + // persistence. The durable outbox remains the sole owner of the + // optimistic bubble until canonical history carries its key; writing + // it into the cache would require cross-database cancellation logic. let update = await outbox.markCommandAwaitingConfirmation(id: command.id) guard update != .unavailable else { self.applyTransportHealth(false) @@ -826,25 +825,6 @@ extension OpenClawChatViewModel { } } - /// Appends a flushed background-session turn to that session's cached - /// transcript so a cold offline reopen shows it before live history. - private func spliceSentCommandIntoCachedTranscript(_ command: OpenClawChatOutboxCommand) async { - guard let transcriptCache else { return } - let key = Self.outboxUserIdempotencyKey(command.id) - let cacheAgentID = Self.transcriptCacheAgentID( - sessionKey: command.sessionKey, - agentID: command.agentID) - var cached = await transcriptCache.loadTranscript( - sessionKey: command.sessionKey, - agentID: cacheAgentID) - guard !cached.contains(where: { $0.idempotencyKey == key }) else { return } - cached.append(Self.outboxUserMessage(for: command)) - await transcriptCache.storeTranscript( - sessionKey: command.sessionKey, - agentID: cacheAgentID, - messages: cached) - } - private func recoverInterruptedOutboxSendsIfNeeded() async -> Bool { guard let outbox else { return false } // The store owns the once-per-process gate so overlapping/replacement diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift new file mode 100644 index 00000000000..52018c49196 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ClientDatabases.swift @@ -0,0 +1,819 @@ +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 +} + +/// 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 + private let legacyDirectoryURLs: [URL] + + public init( + directoryURL: URL, + legacyDirectoryURLs: [URL] = [], + registeredGatewayIDs: Set? = 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)) + self.resolvePendingGatewayRemovals(registeredGatewayIDs: registeredGatewayIDs) + self.importLegacyDatabases(registeredGatewayIDs: registeredGatewayIDs) + } + + 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: Set? = nil) { + self.importLegacyDatabases(registeredGatewayIDs: registeredGatewayIDs) + } + + 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 self.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 self.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 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: Set? = nil) { + 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 { + 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 self.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([self.directoryURL] + self.legacyDirectoryURLs) + for directoryURL in directories { + try Self.removeDatabaseFilesChecked(at: Self.legacyPerGatewayDatabaseURL( + gatewayID: gatewayID, + directoryURL: directoryURL)) + } + } + + private func rejectPreservedSharedLegacyDatabase() throws { + let directories = Set([self.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 + ); + """) + } + 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: Set?) { + 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 self.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 } + 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 + """)) + } + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift index fd293f2bc05..a2f14d54a45 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptCacheStoreTests.swift @@ -1,14 +1,15 @@ import Foundation +import GRDB import OpenClawKit import SQLite3 import Testing @testable import OpenClawChatUI -private func makeDatabaseURL() throws -> URL { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("chat-cache-tests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("chat-cache.sqlite", isDirectory: false) +private func makeDatabaseDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("chat-database-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory } private func cacheMessage( @@ -58,1286 +59,1135 @@ private func messageTexts(_ messages: [OpenClawChatMessage]) -> [String] { messages.map { $0.content.compactMap(\.text).joined() } } +extension OpenClawChatSQLiteTranscriptCache { + fileprivate func storeTestTranscript( + sessionKey: String, + agentID: String? = nil, + messages: [OpenClawChatMessage]) async + { + await self.storeCanonicalTranscript( + sessionKey: sessionKey, + agentID: agentID, + messages: messages, + canonicalMessageIdempotencyKeys: Set(messages.compactMap(\.idempotencyKey))) + } +} + +private struct CacheMessageRowProbe: Sendable { + let position: Int + let idempotencyKey: String? + let payloadJSON: String +} + private func outboxCommand( id: String = UUID().uuidString, sessionKey: String = "main", text: String, attachments: [OpenClawChatOutboxAttachment] = [], thinking: String = "off", - createdAt: Double = Date().timeIntervalSince1970) -> OpenClawChatOutboxCommand + createdAt: Double = Date().timeIntervalSince1970, + status: OpenClawChatOutboxCommand.Status = .queued) -> OpenClawChatOutboxCommand { OpenClawChatOutboxCommand( id: id, sessionKey: sessionKey, + deliverySessionKey: "agent:main:main", + routingContract: "per-sender|main|main", + agentID: "main", text: text, attachments: attachments, thinking: thinking, createdAt: createdAt, - status: .queued, + status: status, retryCount: 0, lastError: nil) } +private func withRawDatabase(at url: URL, _ body: (OpaquePointer) throws -> Void) throws { + var raw: OpaquePointer? + #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) + let database = try #require(raw) + defer { sqlite3_close_v2(database) } + try body(database) +} + +private func execute(_ database: OpaquePointer, _ sql: String) { + #expect(sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK) +} + +private func createLegacyV2Database( + at url: URL, + gatewayID: String, + commandID: String, + text: String = "preserve me") throws +{ + try withRawDatabase(at: url) { raw in + execute(raw, """ + CREATE TABLE outbox_commands( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_uuid TEXT NOT NULL UNIQUE, + gateway_id TEXT NOT NULL, + session_key TEXT NOT NULL, + text TEXT NOT NULL, + thinking TEXT NOT NULL, + created_at REAL NOT NULL, + status TEXT NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' + ); + INSERT INTO outbox_commands( + client_uuid, gateway_id, session_key, text, thinking, + created_at, status, retry_count, last_error + ) VALUES ('\(commandID)', '\(gatewayID)', 'main', '\(text)', 'off', 1, 'queued', 2, ''); + PRAGMA user_version = 2; + """) + } +} + struct ChatTranscriptCacheStoreTests { - @Test func `verified routing identity survives a cold store reopen`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let identity = try #require(OpenClawChatSessionRoutingIdentity( - scope: " Per-Sender ", - mainSessionKey: " Work ", - defaultAgentID: " Main ")) - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + @Test func `one installation owns exactly the two named databases`() throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } - await store.storeSessionRoutingIdentity(identity) - #expect(OpenClawChatSQLiteTranscriptCache.loadSessionRoutingIdentity( - databaseURL: url, - gatewayID: "gw-a") == identity) + _ = try OpenClawClientDatabases(directoryURL: directory) - let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await reopened.loadSessionRoutingIdentity() == identity) - #expect(identity.contract == "per-sender|work|main") + let sqliteFiles = try FileManager.default.contentsOfDirectory(atPath: directory.path) + .filter { $0.hasSuffix(".sqlite") } + .sorted() + #expect(sqliteFiles == ["client-state.sqlite", "gateway-cache.sqlite"]) } - @Test func `transcript and sessions round trip`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") + @Test func `full removal deletes both databases legacy files and sidecars`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + await databases.store(gatewayID: "gw-a").storeSessions([ + cacheSessionEntry(key: "main", updatedAt: 1), + ]) + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + try withRawDatabase(at: legacyURL) { raw in + execute(raw, "PRAGMA user_version = 99;") + } + try Data("sidecar".utf8).write(to: URL(fileURLWithPath: legacyURL.path + "-wal")) + try databases.close() + try OpenClawClientDatabases.removeDatabaseFiles(in: directory) + + for filename in [ + OpenClawClientDatabases.gatewayCacheFilename, + OpenClawClientDatabases.clientStateFilename, + legacyURL.lastPathComponent, + ] { + for suffix in ["", "-wal", "-shm", "-journal"] { + #expect(!FileManager.default.fileExists( + atPath: directory.appendingPathComponent(filename).path + suffix)) + } + } + } + + @Test func `transcript and sessions round trip as row JSON`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") let messages = [ cacheMessage(role: "user", text: "hello", timestamp: 1000, idempotencyKey: "run-1:user"), - cacheMessage(role: "assistant", text: "hi there", timestamp: 2000, idempotencyKey: "run-1"), + cacheMessage(role: "assistant", text: "hi", timestamp: 2000, idempotencyKey: "run-1"), ] - await store.storeTranscript(sessionKey: "main", messages: messages) + + await store.storeTestTranscript(sessionKey: "main", messages: messages) await store.storeSessions([cacheSessionEntry(key: "main", updatedAt: 2000)]) - let loaded = await store.loadTranscript(sessionKey: "main") - #expect(messageTexts(loaded) == ["hello", "hi there"]) - #expect(loaded.map(\.role) == ["user", "assistant"]) - #expect(loaded.map(\.idempotencyKey) == ["run-1:user", "run-1"]) - #expect(loaded.map(\.timestamp) == [1000, 2000]) - - let sessions = await store.loadSessions() - #expect(sessions.map(\.key) == ["main"]) - - #expect(await store.loadTranscript(sessionKey: "unknown").isEmpty) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["hello", "hi"]) + #expect(await store.loadSessions().map(\.key) == ["main"]) + let messageRows = try await databases.cacheQueue.read { db in + try Row.fetchAll(db, sql: """ + SELECT position, timestamp_ms, idempotency_key, payload_json + FROM cached_messages WHERE gateway_id = 'gw-a' ORDER BY position + """).map { row in + CacheMessageRowProbe( + position: row["position"], + idempotencyKey: row["idempotency_key"], + payloadJSON: row["payload_json"]) + } + } + #expect(messageRows.count == 2) + #expect(messageRows.map(\.position) == [0, 1]) + #expect(messageRows.map(\.idempotencyKey) == ["run-1:user", "run-1"]) + #expect(messageRows[0].payloadJSON.contains("\"role\":\"user\"")) + #expect(!messageRows[0].payloadJSON.hasPrefix("[")) } - @Test func `transcript JSON keeps only bounded diff details`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + @Test func `cache format mismatch rebuilds without touching client state`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let stateIdentity = try #require(OpenClawChatSessionRoutingIdentity( + scope: "per-sender", + mainSessionKey: "main", + defaultAgentID: "main")) + do { + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + await store.storeSessionRoutingIdentity(stateIdentity) + } + let cacheURL = directory.appendingPathComponent("gateway-cache.sqlite") + try withRawDatabase(at: cacheURL) { raw in + execute(raw, "UPDATE cache_metadata SET format_version = 999 WHERE id = 1") + execute(raw, "CREATE TABLE disposable_old_shape(value TEXT)") + } + + let reopened = try OpenClawClientDatabases(directoryURL: directory) + + #expect(reopened.loadSessionRoutingIdentity(gatewayID: "gw-a") == stateIdentity) + #expect(try await reopened.cacheQueue.read { db in try db.tableExists("disposable_old_shape") } == false) + #expect(try await reopened.cacheQueue.read { db in + try Int.fetchOne(db, sql: "SELECT format_version FROM cache_metadata WHERE id = 1") + } == 1) + } + + @Test func `corrupt cache rebuilds while corrupt client state is preserved`() async throws { + let cacheDirectory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: cacheDirectory) } + let cacheURL = cacheDirectory.appendingPathComponent("gateway-cache.sqlite") + try Data("not sqlite".utf8).write(to: cacheURL) + let repaired = try OpenClawClientDatabases(directoryURL: cacheDirectory) + #expect(try await repaired.cacheQueue.read { db in try db.tableExists("cached_messages") }) + + let stateDirectory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: stateDirectory) } + let stateURL = stateDirectory.appendingPathComponent("client-state.sqlite") + let bytes = Data("durable bytes must survive".utf8) + try bytes.write(to: stateURL) + #expect(throws: (any Error).self) { + _ = try OpenClawClientDatabases(directoryURL: stateDirectory) + } + #expect(try Data(contentsOf: stateURL) == bytes) + } + + @Test func `transcripts are scoped by gateway and agent in one cache`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let storeA = databases.store(gatewayID: "gw-a") + let storeB = databases.store(gatewayID: "gw-b") + + await storeA.storeTestTranscript( + sessionKey: "global", + agentID: "agent-a", + messages: [cacheMessage(role: "user", text: "A", timestamp: 1)]) + await storeA.storeTestTranscript( + sessionKey: "global", + agentID: "agent-b", + messages: [cacheMessage(role: "user", text: "B", timestamp: 2)]) + await storeB.storeTestTranscript( + sessionKey: "global", + agentID: "agent-a", + messages: [cacheMessage(role: "user", text: "other gateway", timestamp: 3)]) + + #expect(await messageTexts(storeA.loadTranscript(sessionKey: "global", agentID: "agent-a")) == ["A"]) + #expect(await messageTexts(storeA.loadTranscript(sessionKey: "global", agentID: "agent-b")) == ["B"]) + #expect(await messageTexts(storeB.loadTranscript(sessionKey: "global", agentID: "agent-a")) == [ + "other gateway", + ]) + #expect(await storeA.loadTranscript(sessionKey: "global").isEmpty) + } + + @Test func `cache bounds sessions messages and transcript partitions`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a") + + let sessions = (0..<(OpenClawChatSQLiteTranscriptCache.maxCachedSessions + 10)).map { + cacheSessionEntry(key: "s\($0)", updatedAt: Double($0)) + } + await store.storeSessions(sessions) + #expect(await store.loadSessions().count == OpenClawChatSQLiteTranscriptCache.maxCachedSessions) + #expect(await store.loadSessions().contains(where: { $0.key == "s0" }) == false) + + let messages = (0..<(OpenClawChatSQLiteTranscriptCache.maxCachedMessagesPerSession + 20)).map { + cacheMessage(role: "user", text: "m\($0)", timestamp: Double($0)) + } + await store.storeTestTranscript(sessionKey: "bounded", messages: messages) + #expect(await store.loadTranscript(sessionKey: "bounded").count == + OpenClawChatSQLiteTranscriptCache.maxCachedMessagesPerSession) + #expect(await messageTexts(store.loadTranscript(sessionKey: "bounded")).first == "m20") + + for index in 0...OpenClawChatSQLiteTranscriptCache.maxCachedTranscripts { + await store.storeTestTranscript( + sessionKey: "partition-\(index)", + messages: [cacheMessage(role: "user", text: "p\(index)", timestamp: Double(index))]) + } + #expect(await store.loadTranscript(sessionKey: "partition-0").isEmpty) + #expect(await store.loadTranscript( + sessionKey: "partition-\(OpenClawChatSQLiteTranscriptCache.maxCachedTranscripts)").isEmpty == false) + } + + @Test func `empty transcript deletes its partition`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + await store.storeTestTranscript( + sessionKey: "main", + messages: [cacheMessage(role: "user", text: "old", timestamp: 1)]) + await store.storeTestTranscript(sessionKey: "main", messages: []) + + #expect(await store.loadTranscript(sessionKey: "main").isEmpty) + #expect(try await databases.cacheQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_transcripts") + } == 0) + } + + @Test func `canonical cache excludes optimistic outbox rows`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "queued", text: "local"))) + let snapshot = [ + cacheMessage(role: "user", text: "local", timestamp: 1, idempotencyKey: "queued:user"), + cacheMessage(role: "assistant", text: "canonical", timestamp: 2, idempotencyKey: "other"), + ] + + await store.storeCanonicalTranscript( + sessionKey: "main", + agentID: nil, + messages: snapshot, + canonicalMessageIdempotencyKeys: ["other"]) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["canonical"]) + #expect(await store.loadCommands().map(\.id) == ["queued"]) + + await store.storeCanonicalTranscript( + sessionKey: "main", + agentID: nil, + messages: snapshot, + canonicalMessageIdempotencyKeys: ["queued:user", "other"]) + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["local", "canonical"]) + } + + @Test func `canceled optimistic row cannot reenter cache from a stale snapshot`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "canceled", text: "local"))) + let capturedBeforeCancellation = [ + cacheMessage(role: "user", text: "local", timestamp: 1, idempotencyKey: "canceled:user"), + ] + + #expect(await store.cancelCommand(id: "canceled") == .updated) + await store.storeCanonicalTranscript( + sessionKey: "main", + agentID: nil, + messages: capturedBeforeCancellation, + canonicalMessageIdempotencyKeys: []) + + #expect(await store.loadTranscript(sessionKey: "main").isEmpty) + } + + @Test func `malformed cache partitions are discarded atomically`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + await store.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)]) + await store.storeTestTranscript( + sessionKey: "main", + messages: [cacheMessage(role: "assistant", text: "cached", timestamp: 1)]) + try await databases.cacheQueue.write { db in + try db.execute( + sql: "UPDATE cached_sessions SET payload_json = 'not-json' WHERE gateway_id = 'gw-a'") + try db.execute( + sql: "UPDATE cached_messages SET payload_json = 'not-json' WHERE gateway_id = 'gw-a'") + } + + #expect(await store.loadSessions().isEmpty) + #expect(await store.loadTranscript(sessionKey: "main").isEmpty) + #expect(try await databases.cacheQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_sessions WHERE gateway_id = 'gw-a'") + } == 0) + #expect(try await databases.cacheQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM cached_transcripts WHERE gateway_id = 'gw-a'") + } == 0) + } + + @Test func `canonical merge preserves newer cache rows`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a") + await store.storeTestTranscript(sessionKey: "main", messages: [ + cacheMessage(role: "assistant", text: "newer", timestamp: 2, idempotencyKey: "newer"), + ]) + await store.mergeCanonicalTranscriptMessage( + sessionKey: "main", + agentID: nil, + message: cacheMessage(role: "user", text: "confirmed", timestamp: 1, idempotencyKey: "confirmed:user"), + canonicalMessageIdempotencyKey: "confirmed:user") + #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["confirmed", "newer"]) + } + + @Test func `concurrent canonical merges do not lose messages`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a") + + await withTaskGroup(of: Void.self) { group in + for index in 0..<20 { + group.addTask { + let key = "merge-\(index)" + await store.mergeCanonicalTranscriptMessage( + sessionKey: "main", + agentID: nil, + message: cacheMessage( + role: "assistant", + text: key, + timestamp: Double(index), + idempotencyKey: key), + canonicalMessageIdempotencyKey: key) + } + } + } + + #expect(await Set(messageTexts(store.loadTranscript(sessionKey: "main"))) == + Set((0..<20).map { "merge-\($0)" })) + } + + @Test func `cache projection strips payloads and keeps bounded diffs`() throws { let oversizedDiff = "+1 " + String(repeating: "x", count: 64100) let message = OpenClawChatMessage( role: "toolResult", - content: [ - OpenClawChatMessageContent( - type: "tool_result", - text: "done", - mimeType: nil, - fileName: nil, - content: nil, - arguments: AnyCodable(["oldText": AnyCodable("large proposal")]), - details: AnyCodable([ - "diff": AnyCodable("+1 block diff"), - "ignored": AnyCodable("drop me"), - ]), - isError: true), - OpenClawChatMessageContent( - type: "tool_result", - text: "no diff", - mimeType: nil, - fileName: nil, - content: nil, - details: AnyCodable(["ignored": AnyCodable("drop me")])), - ], - timestamp: 1, - details: AnyCodable([ - "diff": AnyCodable(oversizedDiff), - "ignored": AnyCodable("drop me"), - ]), - isError: true) - - let cacheable = try #require(OpenClawChatSQLiteTranscriptCache.cacheableMessages([message]).first) - #expect(cacheable.content.allSatisfy { $0.arguments == nil }) - #expect(Set(cacheable.details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"]) - #expect(cacheable.details?.dictionaryValue?["diff"]?.stringValue?.utf16.count == 64000) - #expect(cacheable.details?.dictionaryValue?["diff"]?.stringValue?.hasSuffix("\n...(truncated)...") == true) - #expect(Set(cacheable.content[0].details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"]) - #expect(cacheable.content[1].details == nil) - #expect(cacheable.isError == true) - #expect(cacheable.content[0].isError == true) - - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - await store.storeTranscript(sessionKey: "main", messages: [message]) - let decoded = try #require(await store.loadTranscript(sessionKey: "main").first) - #expect(decoded.details == cacheable.details) - #expect(decoded.content[0].details == cacheable.content[0].details) - #expect(ChatToolDiff.resolveDiff(name: "edit", arguments: nil, details: decoded.details)?.stat == nil) - } - - @Test func `transcript cache keeps bounded patch envelopes and strips other arguments`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let oversizedInput = "*** Begin Patch\n*** Add File: cached.txt\n" + - String(repeating: "+line\n", count: 12000) + "*** End Patch" - let message = OpenClawChatMessage( - role: "assistant", content: [ OpenClawChatMessageContent( type: "toolCall", - text: nil, - mimeType: nil, - fileName: nil, - content: nil, + text: "done", + mimeType: "image/jpeg", + fileName: "photo.jpg", + content: AnyCodable(String(repeating: "payload", count: 1000)), name: "apply_patch", arguments: AnyCodable([ - "input": AnyCodable(oversizedInput), - "patch": AnyCodable("ignored alias"), - "ignored": AnyCodable("drop me"), - ])), - OpenClawChatMessageContent( - type: "toolCall", - text: nil, - mimeType: nil, - fileName: nil, - content: nil, - name: "exec", - arguments: AnyCodable(["command": AnyCodable("echo secret")])), + "input": AnyCodable(oversizedDiff), + "ignored": AnyCodable("drop"), + ]), + details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")])), ], - timestamp: 1) + timestamp: 1, + details: AnyCodable(["diff": AnyCodable(oversizedDiff), "ignored": AnyCodable("drop")])) - let cacheable = try #require(OpenClawChatSQLiteTranscriptCache.cacheableMessages([message]).first) - let patchArguments = try #require(cacheable.content[0].arguments?.dictionaryValue) - #expect(Set(patchArguments.keys) == ["input"]) - #expect(patchArguments["input"]?.stringValue?.utf16.count == 64000) - #expect(patchArguments["input"]?.stringValue?.hasSuffix("\n...(truncated)...") == true) - #expect(cacheable.content[1].arguments == nil) - - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - await store.storeTranscript(sessionKey: "main", messages: [message]) - let decoded = try #require(await store.loadTranscript(sessionKey: "main").first) - #expect(decoded.content[0].arguments == cacheable.content[0].arguments) - #expect(decoded.content[1].arguments == nil) - let resolved = try #require(ChatToolDiff.resolveDiff( - name: "apply_patch", - arguments: decoded.content[0].arguments, - details: nil)) - #expect(resolved.lines.last?.kind == .skip) - #expect(resolved.stat == nil) + let cached = try #require(OpenClawChatSQLiteTranscriptCache.cacheableMessages([message]).first) + #expect(cached.content[0].content == nil) + #expect(cached.content[0].thinkingSignature == nil) + #expect(Set(cached.content[0].arguments?.dictionaryValue?.keys.map(\.self) ?? []) == ["input"]) + #expect(cached.content[0].arguments?.dictionaryValue?["input"]?.stringValue?.utf16.count == 64000) + #expect(Set(cached.details?.dictionaryValue?.keys.map(\.self) ?? []) == ["diff"]) } - @Test func `transcript keeps only most recent messages within bound`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let bound = OpenClawChatSQLiteTranscriptCache.maxCachedMessagesPerSession - - let messages = (0..<(bound + 50)).map { index in - cacheMessage(role: "user", text: "m\(index)", timestamp: Double(index)) - } - await store.storeTranscript(sessionKey: "main", messages: messages) - - let loaded = await store.loadTranscript(sessionKey: "main") - #expect(loaded.count == bound) - #expect(messageTexts(loaded).first == "m50") - #expect(messageTexts(loaded).last == "m\(bound + 49)") - } - - @Test func `sessions list is bounded to most recently updated`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let bound = OpenClawChatSQLiteTranscriptCache.maxCachedSessions - - let sessions = (0..<(bound + 10)).map { index in - cacheSessionEntry(key: "s\(index)", updatedAt: Double(index)) - } - await store.storeSessions(sessions) - - let loaded = await store.loadSessions() - #expect(loaded.count == bound) - // Highest updatedAt survives, oldest entries are dropped. - #expect(loaded.map(\.key).contains("s\(bound + 9)")) - #expect(!loaded.map(\.key).contains("s0")) - } - - @Test func `transcript eviction keeps most recent sessions`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let bound = OpenClawChatSQLiteTranscriptCache.maxCachedTranscripts - - for index in 0..<(bound + 5) { - await store.storeTranscript( - sessionKey: "s\(index)", - messages: [cacheMessage(role: "user", text: "m\(index)", timestamp: Double(index))]) - } - - // The five oldest transcripts were evicted; the newest ones remain. - for index in 0..<5 { - #expect(await store.loadTranscript(sessionKey: "s\(index)").isEmpty) - } - for index in 5..<(bound + 5) { - #expect(await !(store.loadTranscript(sessionKey: "s\(index)").isEmpty)) - } - } - - @Test func `transcripts are scoped per gateway identity`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let storeA = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let storeB = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-b") - - await storeA.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "gateway A secret", timestamp: 1)]) - await storeA.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)]) - - #expect(await storeB.loadTranscript(sessionKey: "main").isEmpty) - #expect(await storeB.loadSessions().isEmpty) - - await storeB.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "gateway B", timestamp: 2)]) - #expect(await messageTexts(storeA.loadTranscript(sessionKey: "main")) == ["gateway A secret"]) - #expect(await messageTexts(storeB.loadTranscript(sessionKey: "main")) == ["gateway B"]) - } - - @Test func `global transcripts are scoped per agent identity`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - - await store.storeTranscript( - sessionKey: "global", - agentID: "agent-a", - messages: [cacheMessage( - role: "user", - text: "agent A", - timestamp: 1, - idempotencyKey: "c-agent-a:user")]) - await store.storeTranscript( - sessionKey: "global", - agentID: "agent-b", - messages: [cacheMessage(role: "user", text: "agent B", timestamp: 2)]) - - let agentAMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-a") - let agentBMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-b") - #expect(messageTexts(agentAMessages) == ["agent A"]) - #expect(messageTexts(agentBMessages) == ["agent B"]) - #expect(await store.loadTranscript(sessionKey: "global").isEmpty) - - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-agent-a", - sessionKey: "global", - agentID: "agent-a", - text: "agent A", - thinking: "off", - createdAt: Date().timeIntervalSince1970, - status: .queued, - retryCount: 0, - lastError: nil))) - #expect(await store.cancelCommand(id: "c-agent-a") == .updated) - #expect(await store.loadTranscript(sessionKey: "global", agentID: "agent-a").isEmpty) - let survivingAgentBMessages = await store.loadTranscript(sessionKey: "global", agentID: "agent-b") - #expect(messageTexts(survivingAgentBMessages) == ["agent B"]) - } - - @Test func `empty transcript store clears cached row`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - - await store.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "old", timestamp: 1)]) - await store.storeTranscript(sessionKey: "main", messages: []) - #expect(await store.loadTranscript(sessionKey: "main").isEmpty) - } - - @Test func `reset retirement permits physical removal of all gateway rows`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let storeA = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let storeB = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-b") - await storeA.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "gateway A", timestamp: 1)]) - await storeA.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)]) - await storeB.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "gateway B", timestamp: 2)]) - - await storeA.retire() - await storeB.retire() - OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: url) - await storeA.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "late write", timestamp: 3)]) - - let readerA = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let readerB = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-b") - #expect(await storeA.loadTranscript(sessionKey: "main").isEmpty) - #expect(await storeA.loadSessions().isEmpty) - #expect(await readerA.loadTranscript(sessionKey: "main").isEmpty) - #expect(await readerB.loadTranscript(sessionKey: "main").isEmpty) - } - - @Test func `removing one gateway database preserves another`() async throws { - let urlA = try makeDatabaseURL() - let directory = urlA.deletingLastPathComponent() - let urlB = directory.appendingPathComponent("chat-cache-b.sqlite", isDirectory: false) + @Test func `gateway removal deletes only that gateways cache and state`() async throws { + let directory = try makeDatabaseDirectory() defer { try? FileManager.default.removeItem(at: directory) } - let storeA = OpenClawChatSQLiteTranscriptCache(databaseURL: urlA, gatewayID: "gw-a") - let storeB = OpenClawChatSQLiteTranscriptCache(databaseURL: urlB, gatewayID: "gw-b") + let databases = try OpenClawClientDatabases(directoryURL: directory) + let storeA = databases.store(gatewayID: "gw-a") + let storeB = databases.store(gatewayID: "gw-b") await storeA.storeSessions([cacheSessionEntry(key: "a", updatedAt: 1)]) - await storeA.storeTranscript( - sessionKey: "a", - messages: [cacheMessage(role: "user", text: "gateway A", timestamp: 1)]) await storeB.storeSessions([cacheSessionEntry(key: "b", updatedAt: 2)]) - await storeB.storeTranscript( - sessionKey: "b", - messages: [cacheMessage(role: "user", text: "gateway B", timestamp: 2)]) + #expect(await storeA.enqueueCommand(outboxCommand(id: "a", text: "A"))) + #expect(await storeB.enqueueCommand(outboxCommand(id: "b", text: "B"))) - await storeA.retire() - OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: urlA) + try databases.removeGatewayData(gatewayID: "gw-a") - let readerA = OpenClawChatSQLiteTranscriptCache(databaseURL: urlA, gatewayID: "gw-a") - #expect(await readerA.loadSessions().isEmpty) - #expect(await readerA.loadTranscript(sessionKey: "a").isEmpty) + #expect(await storeA.loadSessions().isEmpty) + #expect(await storeA.loadCommands().isEmpty) #expect(await storeB.loadSessions().map(\.key) == ["b"]) - #expect(await messageTexts(storeB.loadTranscript(sessionKey: "b")) == ["gateway B"]) + #expect(await storeB.loadCommands().map(\.id) == ["b"]) } - @Test func `attachment payloads are not persisted`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - - let message = OpenClawChatMessage( - role: "user", - content: [ - OpenClawChatMessageContent( - type: "text", - text: "See attached.", - mimeType: nil, - fileName: nil, - content: nil), - OpenClawChatMessageContent( - type: "image", - text: nil, - mimeType: "image/png", - fileName: "photo.png", - content: AnyCodable("aGVsbG8tYmluYXJ5LWJsb2I=")), - ], - timestamp: 1000) - await store.storeTranscript(sessionKey: "main", messages: [message]) - - let loaded = await store.loadTranscript(sessionKey: "main") - let items = try #require(loaded.first?.content) - #expect(items.count == 2) - // Text and small descriptors survive; binary payloads never hit disk. - #expect(items[0].text == "See attached.") - #expect(items[1].fileName == "photo.png") - #expect(items[1].content == nil) - } - - @Test func `v1 transcript cache migrates to durable outbox schema`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + @Test func `staged gateway removal reconciles against the pairing registry`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - await store.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "old-schema", timestamp: 1)]) - await store.retire() + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + await store.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)]) + #expect(await store.enqueueCommand(outboxCommand(id: "keep", text: "pending"))) + try databases.stageGatewayRemoval(gatewayID: "gw-a") + #expect(await store.loadSessions().map(\.key) == ["main"]) + #expect(await store.loadCommands().map(\.id) == ["keep"]) + try databases.close() } - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec(raw, "DROP TABLE outbox_commands", nil, nil, nil) == SQLITE_OK) - #expect(sqlite3_exec(raw, "PRAGMA user_version = 1", nil, nil, nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await messageTexts(migrated.loadTranscript(sessionKey: "main")) == ["old-schema"]) - #expect(await migrated.enqueueCommand(outboxCommand(id: "c-1", text: "preserved migration"))) - #expect(await migrated.loadCommands().map(\.text) == ["preserved migration"]) - } - - @Test func `v2 migration parks rows without a routing contract`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let now = Date().timeIntervalSince1970 do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-global", - sessionKey: "global", - agentID: "agent-a", - text: "targeted before downgrade", - thinking: "off", - createdAt: now, - status: .queued, - retryCount: 0, - lastError: nil))) - #expect(await store.enqueueCommand(outboxCommand( - id: "c-main", - sessionKey: "main", - text: "mutable main alias", - createdAt: now + 0.5))) - #expect(await store.enqueueCommand(outboxCommand( - id: "c-scoped", - sessionKey: "agent:agent-a:main", - text: "already scoped", - createdAt: now + 1))) - #expect(await store.enqueueCommand(outboxCommand( - id: "c-matrix", - sessionKey: "agent:agent-a:matrix:channel:!MixedRoomAbCdEf:example.org", - text: "case-sensitive room", - createdAt: now + 2))) - #expect(await store.enqueueCommand(outboxCommand( - id: "c-empty-agent", - sessionKey: "agent::main", - text: "missing agent", - createdAt: now + 3))) - #expect(await store.enqueueCommand(outboxCommand( - id: "c-empty-rest", - sessionKey: "agent:agent-a:", - text: "missing session", - createdAt: now + 4))) - await store.retire() + let registered = try OpenClawClientDatabases( + directoryURL: directory, + registeredGatewayIDs: ["gw-a"]) + #expect(await registered.store(gatewayID: "gw-a").loadCommands().map(\.id) == ["keep"]) + try registered.stageGatewayRemoval(gatewayID: "gw-a") + try registered.close() } - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "UPDATE outbox_commands SET status = 'sending' WHERE client_uuid = 'c-scoped'", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "UPDATE outbox_commands SET status = 'awaiting_confirmation' WHERE client_uuid = 'c-matrix'", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec(raw, "ALTER TABLE outbox_commands DROP COLUMN agent_id", nil, nil, nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN delivery_session_key", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN routing_contract", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachment_bytes", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachments", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec(raw, "PRAGMA user_version = 2", nil, nil, nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let commands = await migrated.loadCommands() - #expect(commands.map(\.agentID) == [nil, nil, nil, nil, nil, nil]) - #expect(commands.map(\.deliverySessionKey) == [ - "", - "", - "", - "", - "", - "", - ]) - #expect(commands.map(\.status) == [.failed, .failed, .failed, .failed, .failed, .failed]) - #expect(commands.map(\.lastError) == [ - OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, - OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, - OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError, - OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError, - OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, - OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, - ]) - #expect(await migrated.markCommandRetriedIfPresent( - id: "c-global", - agentID: "agent-b", - deliverySessionKey: "global", - routingContract: "per-sender|main|agent-b") == .updated) - let retried = await migrated.loadCommands().first { $0.id == "c-global" } - #expect(retried?.agentID == "agent-b") - #expect(retried?.deliverySessionKey == "global") - #expect(retried?.routingContract == "per-sender|main|agent-b") - #expect(retried?.status == .queued) + let forgotten = try OpenClawClientDatabases( + directoryURL: directory, + registeredGatewayIDs: []) + #expect(await forgotten.store(gatewayID: "gw-a").loadSessions().isEmpty) + #expect(await forgotten.store(gatewayID: "gw-a").loadCommands().isEmpty) + #expect(try await forgotten.stateQueue.read { db in + try String.fetchOne( + db, + sql: "SELECT gateway_hash FROM forgotten_gateways WHERE gateway_id IS NULL") + } == OpenClawClientDatabases.gatewayIdentityHash("gw-a")) } - @Test func `unknown failed command can retry without an agent`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-unknown", - sessionKey: "unknown", - deliverySessionKey: "unknown", - routingContract: "per-sender|main|main", - agentID: nil, - text: "retry reserved", - thinking: "off", - createdAt: Date().timeIntervalSince1970, - status: .failed, - retryCount: 1, - lastError: "failed"))) - #expect(await store.loadCommands().first?.lastError == "failed") + @Test func `commit started recovery finishes even while gateway remains registered`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + do { + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + await store.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)]) + #expect(await store.enqueueCommand(outboxCommand(id: "remove", text: "pending"))) + try databases.stageGatewayRemoval(gatewayID: "gw-a") + // Simulate termination immediately after the irreversible state + // transaction but before cache cleanup and tombstone finalization. + try await databases.stateQueue.write { db in + try db.execute( + sql: "UPDATE forgotten_gateways SET cleanup_phase = 2 WHERE gateway_id = ?", + arguments: ["gw-a"]) + try db.execute( + sql: "DELETE FROM outbox_commands WHERE gateway_id = ?", + arguments: ["gw-a"]) + } + try databases.close() + } - #expect(await store.markCommandRetriedIfPresent( - id: "c-unknown", - agentID: nil, - deliverySessionKey: "unknown", - routingContract: "per-sender|main|main") == .updated) - let command = try #require(await store.loadCommands().first) - #expect(command.status == .queued) - #expect(command.agentID == nil) - #expect(command.deliverySessionKey == "unknown") + let recovered = try OpenClawClientDatabases( + directoryURL: directory, + registeredGatewayIDs: ["gw-a"]) + #expect(await recovered.store(gatewayID: "gw-a").loadSessions().isEmpty) + #expect(await recovered.store(gatewayID: "gw-a").loadCommands().isEmpty) + #expect(try await recovered.stateQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", + arguments: [OpenClawClientDatabases.gatewayIdentityHash("gw-a")]) + } == 0) } - @Test func `retargeted retry removes optimistic row from previous agent cache`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - await store.storeTranscript( + @Test func `hash only scrub marker remains recoverable`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + do { + let databases = try OpenClawClientDatabases(directoryURL: directory) + try databases.stageGatewayRemoval(gatewayID: "gw-a") + try await databases.stateQueue.write { db in + try db.execute( + sql: """ + UPDATE forgotten_gateways + SET gateway_id = NULL, cleanup_phase = 3, restore_finalized = 0 + WHERE gateway_id = ? + """, + arguments: ["gw-a"]) + } + try databases.close() + } + + let recovered = try OpenClawClientDatabases(directoryURL: directory) + #expect(try await recovered.stateQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", + arguments: [OpenClawClientDatabases.gatewayIdentityHash("gw-a")]) + } == 0) + } + + @Test func `unknown registry preserves a cancelable staged removal`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + do { + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "keep", text: "pending"))) + try databases.stageGatewayRemoval(gatewayID: "gw-a") + try databases.close() + } + + let reopened = try OpenClawClientDatabases(directoryURL: directory) + #expect(await reopened.store(gatewayID: "gw-a").loadCommands().map(\.id) == ["keep"]) + #expect(try await reopened.stateQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", + arguments: [OpenClawClientDatabases.gatewayIdentityHash("gw-a")]) + } == 1) + } + + @Test func `pending removal marker gates writable facade recreation`() throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + + #expect(!databases.hasPendingGatewayRemoval(gatewayID: "gw-a")) + try databases.stageGatewayRemoval(gatewayID: "gw-a") + #expect(databases.hasPendingGatewayRemoval(gatewayID: "gw-a")) + try databases.cancelGatewayRemoval(gatewayID: "gw-a") + #expect(!databases.hasPendingGatewayRemoval(gatewayID: "gw-a")) + } + + @Test func `cancelable stage does not suppress legacy state import`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + try databases.stageGatewayRemoval(gatewayID: "gw-a") + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + try createLegacyV2Database(at: legacyURL, gatewayID: "gw-a", commandID: "restore-on-cancel") + + databases.retryLegacyImport() + + #expect(await databases.store(gatewayID: "gw-a").loadCommands().map(\.id) == ["restore-on-cancel"]) + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + } + + @Test func `one broken pending removal does not block another gateway`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + do { + let databases = try OpenClawClientDatabases(directoryURL: directory) + let store = databases.store(gatewayID: "z-good") + await store.storeSessions([cacheSessionEntry(key: "main", updatedAt: 1)]) + #expect(await store.enqueueCommand(outboxCommand(id: "remove", text: "pending"))) + try databases.stageGatewayRemoval(gatewayID: "z-good") + try await databases.stateQueue.write { db in + try db.execute( + sql: "UPDATE forgotten_gateways SET cleanup_phase = 2 WHERE gateway_id = ?", + arguments: ["z-good"]) + try db.execute( + sql: """ + INSERT INTO forgotten_gateways( + gateway_hash, gateway_id, forgotten_at, cleanup_phase, restore_finalized + ) VALUES (?, ?, 0, 2, 0) + """, + arguments: [String(repeating: "0", count: 64), "a-broken"]) + } + try databases.close() + } + + let recovered = try OpenClawClientDatabases( + directoryURL: directory, + registeredGatewayIDs: []) + #expect(await recovered.store(gatewayID: "z-good").loadSessions().isEmpty) + #expect(await recovered.store(gatewayID: "z-good").loadCommands().isEmpty) + #expect(try await recovered.stateQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_id = ?", + arguments: ["a-broken"]) + } == 2) + } + + @Test func `canceling a repeated forget preserves the finalized tombstone`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + try databases.removeGatewayData(gatewayID: "gw-a") + + try databases.stageGatewayRemoval(gatewayID: "gw-a") + try databases.cancelGatewayRemoval(gatewayID: "gw-a") + + let tombstoneGatewayID: String? = try await databases.stateQueue.read { db in + try String.fetchOne( + db, + sql: """ + SELECT gateway_id FROM forgotten_gateways + WHERE gateway_hash = ? + """, + arguments: [OpenClawClientDatabases.gatewayIdentityHash("gw-a")]) + } + let tombstonePhase = try await databases.stateQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", + arguments: [OpenClawClientDatabases.gatewayIdentityHash("gw-a")]) + } + #expect(tombstoneGatewayID == nil) + #expect(tombstonePhase == 0) + + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + try createLegacyV2Database(at: legacyURL, gatewayID: "gw-a", commandID: "must-not-return") + databases.retryLegacyImport(registeredGatewayIDs: []) + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(await databases.store(gatewayID: "gw-a").loadCommands().isEmpty) + } + + @Test func `gateway removal scrubs its payloads from shared database files`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let storeA = databases.store(gatewayID: "gw-a") + let storeB = databases.store(gatewayID: "gw-b") + let sensitiveText = "forgotten-sensitive-\(UUID().uuidString)" + let sensitiveBytes = Data(sensitiveText.utf8) + let attachment = OpenClawChatOutboxAttachment( + type: "file", + mimeType: "application/octet-stream", + fileName: "secret.bin", + data: sensitiveBytes) + await storeA.storeTestTranscript( sessionKey: "main", - agentID: "agent-a", - messages: [ - cacheMessage( - role: "user", - text: "old owner", - timestamp: 1, - idempotencyKey: "c-retarget:user"), - ]) - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-retarget", - sessionKey: "main", - deliverySessionKey: "agent:agent-a:main", - routingContract: "per-sender|main|agent-a", - agentID: "agent-a", - text: "old owner", - thinking: "off", - createdAt: Date().timeIntervalSince1970, - status: .failed, - retryCount: 1, - lastError: "changed"))) + messages: [cacheMessage(role: "user", text: sensitiveText, timestamp: 1)]) + #expect(await storeA.enqueueCommand(outboxCommand( + id: "sensitive", + text: sensitiveText, + attachments: [attachment]))) + await storeB.storeSessions([cacheSessionEntry(key: "keep", updatedAt: 1)]) - #expect(await store.markCommandRetriedIfPresent( - id: "c-retarget", - agentID: "agent-b", - deliverySessionKey: "agent:agent-b:main", - routingContract: "per-sender|main|agent-b") == .updated) - #expect(await store.loadTranscript(sessionKey: "main", agentID: "agent-a").isEmpty) - let command = try #require(await store.loadCommands().first) - #expect(command.agentID == "agent-b") - #expect(command.status == .queued) + try databases.removeGatewayData(gatewayID: "gw-a") + try databases.close() + + let files = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil) + for file in files where file.lastPathComponent.contains(".sqlite") { + #expect(try Data(contentsOf: file).range(of: sensitiveBytes) == nil) + } + let reopened = try OpenClawClientDatabases(directoryURL: directory) + #expect(await reopened.store(gatewayID: "gw-b").loadSessions().map(\.key) == ["keep"]) } - @Test func `v3 migration preserves delivery ambiguity without a routing contract`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-v3", - sessionKey: "main", - deliverySessionKey: "agent:agent-a:main", - routingContract: "per-sender|main|agent-a", - agentID: "agent-a", - text: "park after downgrade", - thinking: "off", - createdAt: Date().timeIntervalSince1970, - status: .queued, - retryCount: 0, - lastError: nil))) - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-v3-acked", - sessionKey: "main", - deliverySessionKey: "agent:agent-a:main", - routingContract: "per-sender|main|agent-a", - agentID: "agent-a", - text: "possibly delivered", - thinking: "off", - createdAt: Date().timeIntervalSince1970 + 1, - status: .awaitingConfirmation, - retryCount: 0, - lastError: nil))) - await store.retire() - } - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN routing_contract", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachment_bytes", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachments", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec(raw, "PRAGMA user_version = 3", nil, nil, nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let commands = await migrated.loadCommands() - #expect(commands.map(\.status) == [.failed, .failed]) - #expect(commands.map(\.agentID) == [nil, nil]) - #expect(commands.map(\.deliverySessionKey) == ["", ""]) - #expect(commands.map(\.routingContract) == [nil, nil]) - #expect(commands.map(\.lastError) == [ - OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError, - OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError, - ]) - } - - @Test func `v4 migration adds routing identity and attachment storage`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-v4", text: "preserve me"))) - await store.retire() - } - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec(raw, "DROP TABLE gateway_routing_identity", nil, nil, nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachment_bytes", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachments", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec(raw, "PRAGMA user_version = 4", nil, nil, nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await migrated.loadCommands().map(\.id) == ["c-v4"]) - #expect(await migrated.loadSessionRoutingIdentity() == nil) + @Test func `routing identity survives a cold container reopen`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } let identity = try #require(OpenClawChatSessionRoutingIdentity( - scope: "global", - mainSessionKey: "main", - defaultAgentID: "main")) - await migrated.storeSessionRoutingIdentity(identity) - #expect(await migrated.loadSessionRoutingIdentity() == identity) - } - - @Test func `v5 migration preserves commands while adding attachment storage`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + scope: " Per-Sender ", + mainSessionKey: " Work ", + defaultAgentID: " Main ")) do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-v5", text: "preserve me"))) - await store.retire() + let databases = try OpenClawClientDatabases(directoryURL: directory) + await databases.store(gatewayID: "gw-a").storeSessionRoutingIdentity(identity) } - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachment_bytes", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands DROP COLUMN attachments", - nil, - nil, - nil) == SQLITE_OK) - #expect(sqlite3_exec(raw, "PRAGMA user_version = 5", nil, nil, nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - let migrated = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let command = try #require(await migrated.loadCommands().first) - #expect(command.id == "c-v5") - #expect(command.attachments.isEmpty) + let reopened = try OpenClawClientDatabases(directoryURL: directory) + #expect(reopened.loadSessionRoutingIdentity(gatewayID: "gw-a") == identity) + #expect(identity.contract == "per-sender|work|main") + #expect(try await reopened.stateQueue.read { db in + try String.fetchAll(db, sql: "SELECT identifier FROM grdb_migrations") + } == ["client-state-v1"]) } +} - @Test func `unknown schema preserves durable outbox bytes and fails closed`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "do not delete"))) - await store.retire() +struct ClientDatabaseLegacyImportTests { + @Test func `legacy v1 cache is discarded`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + try withRawDatabase(at: legacyURL) { raw in + execute(raw, """ + CREATE TABLE cached_sessions( + gateway_id TEXT PRIMARY KEY, payload TEXT NOT NULL, updated_at REAL NOT NULL + ); + PRAGMA user_version = 1; + """) } - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec(raw, "PRAGMA user_version = 99", nil, nil, nil) == SQLITE_OK) - sqlite3_close_v2(raw) + let databases = try OpenClawClientDatabases(directoryURL: directory) - let blocked = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await blocked.loadCommands().isEmpty) - #expect(await !blocked.enqueueCommand(outboxCommand(id: "c-2", text: "must fail closed"))) - - // Restore the known version to prove the blocked open did not delete - // or rebuild the persistent outbox table. - raw = nil - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "PRAGMA user_version = \(OpenClawChatSQLiteTranscriptCache.schemaVersion)", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - let recovered = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await recovered.loadCommands().map(\.text) == ["do not delete"]) + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(try await databases.stateQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM outbox_commands") + } == 0) } - @Test func `corrupt existing database is preserved and fails closed`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let original = Data("this is not a sqlite database".utf8) - try original.write(to: url) + @Test func `legacy v2 outbox imports parked into client state`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let legacyURL = directory.appendingPathComponent( + String(repeating: "a", count: 64) + ".sqlite") + try createLegacyV2Database(at: legacyURL, gatewayID: "gw-a", commandID: "legacy-v2") - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.loadTranscript(sessionKey: "main").isEmpty) - await store.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "recovered", timestamp: 1)]) - #expect(await store.loadTranscript(sessionKey: "main").isEmpty) - #expect(try Data(contentsOf: url) == original) + let databases = try OpenClawClientDatabases(directoryURL: directory) + let commands = await databases.store(gatewayID: "gw-a").loadCommands() + + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(commands.map(\.id) == ["legacy-v2"]) + #expect(commands.map(\.text) == ["preserve me"]) + #expect(commands.map(\.status) == [.failed]) + #expect(commands.map(\.lastError) == [OpenClawChatSQLiteTranscriptCache.outboxUnknownTargetError]) + #expect(commands.map(\.routingContract) == [nil]) } - @Test func `undecodable row is dropped and treated as miss`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - await store.storeTranscript( - sessionKey: "main", - messages: [cacheMessage(role: "user", text: "seed", timestamp: 1)]) + @Test func `foreground retry imports a legacy database discovered after startup`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + try createLegacyV2Database(at: legacyURL, gatewayID: "gw-a", commandID: "late-legacy") - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "UPDATE cached_transcripts SET payload = '{not json' WHERE session_key = 'main'", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) + databases.retryLegacyImport() - let reader = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await reader.loadTranscript(sessionKey: "main").isEmpty) - // The bad row was deleted, not just skipped. - #expect(await reader.loadTranscript(sessionKey: "main").isEmpty) + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(await databases.store(gatewayID: "gw-a").loadCommands().map(\.id) == ["late-legacy"]) + } + + @Test func `forgotten gateway is never resurrected by a late legacy import`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let databases = try OpenClawClientDatabases(directoryURL: directory) + try databases.removeGatewayData(gatewayID: "gw-forgotten") + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + try createLegacyV2Database( + at: legacyURL, + gatewayID: "gw-forgotten", + commandID: "must-not-return") + + databases.retryLegacyImport(registeredGatewayIDs: []) + + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + let store = databases.store(gatewayID: "gw-forgotten") + #expect(await store.loadCommands().isEmpty) + #expect(try await databases.stateQueue.read { db in + try Int.fetchOne( + db, + sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?", + arguments: [OpenClawClientDatabases.gatewayIdentityHash("gw-forgotten")]) + } == 0) + + #expect(await store.enqueueCommand(outboxCommand(id: "after-repair", text: "new pairing"))) + databases.retryLegacyImport() + #expect(await store.loadCommands().map(\.id) == ["after-repair"]) + } + + @Test func `forget removes an unreadable per gateway legacy database and sidecars`() throws { + let root = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let databaseDirectory = root.appendingPathComponent("databases", isDirectory: true) + let legacyDirectory = root.appendingPathComponent("chat-cache", isDirectory: true) + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) + let gatewayID = "manual|forgotten-secret.example|443" + let legacyURL = OpenClawClientDatabases.legacyPerGatewayDatabaseURL( + gatewayID: gatewayID, + directoryURL: legacyDirectory) + try withRawDatabase(at: legacyURL) { raw in + execute(raw, "PRAGMA user_version = 99;") + } + for suffix in ["-wal", "-shm", "-journal"] { + try Data("legacy-sensitive-bytes".utf8).write(to: URL(fileURLWithPath: legacyURL.path + suffix)) + } + let databases = try OpenClawClientDatabases( + directoryURL: databaseDirectory, + legacyDirectoryURLs: [legacyDirectory]) + #expect(FileManager.default.fileExists(atPath: legacyURL.path)) + + try databases.removeGatewayData(gatewayID: gatewayID) + + for suffix in ["", "-wal", "-shm", "-journal"] { + #expect(!FileManager.default.fileExists(atPath: legacyURL.path + suffix)) + } + #expect(try databases.stateQueue.read { db in + try String.fetchOne( + db, + sql: "SELECT gateway_hash FROM forgotten_gateways WHERE gateway_id IS NULL") + } == OpenClawClientDatabases.gatewayIdentityHash(gatewayID)) + try databases.close() + for suffix in ["", "-wal", "-shm"] { + let url = URL(fileURLWithPath: databaseDirectory + .appendingPathComponent(OpenClawClientDatabases.clientStateFilename).path + suffix) + if FileManager.default.fileExists(atPath: url.path) { + #expect(try Data(contentsOf: url).range(of: Data(gatewayID.utf8)) == nil) + } + } + } + + @Test func `legacy import accepts only gateways in the pairing registry`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let keptURL = OpenClawClientDatabases.legacyPerGatewayDatabaseURL( + gatewayID: "gw-kept", + directoryURL: directory) + let orphanedURL = OpenClawClientDatabases.legacyPerGatewayDatabaseURL( + gatewayID: "gw-orphaned", + directoryURL: directory) + try createLegacyV2Database(at: keptURL, gatewayID: "gw-kept", commandID: "kept") + try createLegacyV2Database(at: orphanedURL, gatewayID: "gw-orphaned", commandID: "orphaned") + + let databases = try OpenClawClientDatabases( + directoryURL: directory, + registeredGatewayIDs: ["gw-kept"]) + + #expect(await databases.store(gatewayID: "gw-kept").loadCommands().map(\.id) == ["kept"]) + #expect(await databases.store(gatewayID: "gw-orphaned").loadCommands().isEmpty) + #expect(!FileManager.default.fileExists(atPath: keptURL.path)) + #expect(FileManager.default.fileExists(atPath: orphanedURL.path)) + } + + @Test func `preserved shared legacy database blocks targeted forget`() async throws { + let root = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let databaseDirectory = root.appendingPathComponent("databases", isDirectory: true) + let legacyDirectory = root.appendingPathComponent("legacy", isDirectory: true) + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) + let legacyURL = legacyDirectory.appendingPathComponent("chat-cache.sqlite") + try withRawDatabase(at: legacyURL) { raw in + execute(raw, "PRAGMA user_version = 99;") + } + let databases = try OpenClawClientDatabases( + directoryURL: databaseDirectory, + legacyDirectoryURLs: [legacyDirectory]) + let store = databases.store(gatewayID: "gw-a") + #expect(await store.enqueueCommand(outboxCommand(id: "keep", text: "not forgotten"))) + + #expect(throws: (any Error).self) { + try databases.removeGatewayData(gatewayID: "gw-a") + } + + #expect(FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(await store.loadCommands().map(\.id) == ["keep"]) + } + + @Test func `legacy v6 imports attachments and routing identity`() async throws { + let directory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let legacyURL = directory.appendingPathComponent("chat-cache.sqlite") + let attachment = OpenClawChatOutboxAttachment( + type: "image", + mimeType: "image/jpeg", + fileName: "photo.jpg", + data: Data([1, 2, 3]), + durationSeconds: nil) + let attachmentsJSON = try #require(String( + data: JSONEncoder().encode([attachment]), + encoding: .utf8)) + try withRawDatabase(at: legacyURL) { raw in + execute(raw, """ + CREATE TABLE outbox_commands( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_uuid TEXT NOT NULL UNIQUE, + gateway_id TEXT NOT NULL, + session_key TEXT NOT NULL, + delivery_session_key TEXT NOT NULL DEFAULT '', + routing_contract TEXT NOT NULL DEFAULT '', + agent_id TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + attachments TEXT NOT NULL DEFAULT '[]', + attachment_bytes INTEGER NOT NULL DEFAULT 0, + thinking TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + status TEXT NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE gateway_routing_identity( + gateway_id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + main_session_key TEXT NOT NULL, + default_agent_id TEXT NOT NULL, + updated_at REAL NOT NULL + ); + """) + var statement: OpaquePointer? + #expect(sqlite3_prepare_v2(raw, """ + INSERT INTO outbox_commands( + client_uuid, gateway_id, session_key, delivery_session_key, + routing_contract, agent_id, text, attachments, attachment_bytes, + thinking, created_at, status, retry_count, last_error + ) VALUES (?, 'gw-a', 'main', 'agent:main:main', + 'per-sender|main|main', 'main', 'with image', ?, 3, + 'off', 1, 'queued', 0, '') + """, -1, &statement, nil) == SQLITE_OK) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, "legacy-v6", -1, transient) + sqlite3_bind_text(statement, 2, attachmentsJSON, -1, transient) + #expect(sqlite3_step(statement) == SQLITE_DONE) + sqlite3_finalize(statement) + execute(raw, """ + INSERT INTO gateway_routing_identity( + gateway_id, scope, main_session_key, default_agent_id, updated_at + ) VALUES ('gw-a', 'per-sender', 'main', 'main', 10); + PRAGMA user_version = 6; + """) + } + + let databases = try OpenClawClientDatabases(directoryURL: directory) + let command = try #require(await databases.store(gatewayID: "gw-a").loadCommands().first) + + #expect(command.id == "legacy-v6") + #expect(command.attachments == [attachment]) + #expect(databases.loadSessionRoutingIdentity(gatewayID: "gw-a")?.contract == "per-sender|main|main") + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + } + + @Test func `unknown or corrupt legacy files remain untouched`() async throws { + let unknownDirectory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: unknownDirectory) } + let unknownURL = unknownDirectory.appendingPathComponent("chat-cache.sqlite") + try withRawDatabase(at: unknownURL) { raw in + execute(raw, "PRAGMA user_version = 999") + } + _ = try OpenClawClientDatabases(directoryURL: unknownDirectory) + #expect(FileManager.default.fileExists(atPath: unknownURL.path)) + + let corruptDirectory = try makeDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: corruptDirectory) } + let corruptURL = corruptDirectory.appendingPathComponent("chat-cache.sqlite") + let bytes = Data("unknown durable format".utf8) + try bytes.write(to: corruptURL) + let databases = try OpenClawClientDatabases(directoryURL: corruptDirectory) + #expect(try Data(contentsOf: corruptURL) == bytes) + #expect(try await databases.stateQueue.read { db in try db.tableExists("outbox_commands") }) } } struct ChatCommandOutboxStoreTests { - @Test func `outbox commands round trip in createdAt order across store instances`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - // Recent timestamps: rows older than the staleness gate would expire. - let now = Date().timeIntervalSince1970 - do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand( - outboxCommand(id: "c-2", text: "second", thinking: "high", createdAt: now - 10))) - #expect(await store.enqueueCommand( - outboxCommand(id: "c-1", text: "first", thinking: "off", createdAt: now - 20))) - } - - // New instance = simulated app relaunch: rows are durable. - let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let loaded = await reopened.loadCommands() - #expect(loaded.map(\.id) == ["c-1", "c-2"]) - #expect(loaded.map(\.text) == ["first", "second"]) - #expect(loaded.map(\.thinking) == ["off", "high"]) - #expect(loaded.map(\.status) == [.queued, .queued]) - #expect(loaded.map(\.retryCount) == [0, 0]) - #expect(loaded.map(\.lastError) == [nil, nil]) - #expect(loaded.map(\.sessionKey) == ["main", "main"]) - } - - @Test func `claims are FIFO and exclusive until the sender advances`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let now = Date().timeIntervalSince1970 - #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "first", createdAt: now))) - #expect(await store.enqueueCommand(outboxCommand(id: "c-2", text: "second", createdAt: now + 1))) - - #expect(await store.claimNextCommand()?.id == "c-1") - #expect(await store.claimNextCommand() == nil) - #expect(await store.markCommandAwaitingConfirmation(id: "c-1") == .updated) - #expect(await store.claimNextCommand()?.id == "c-2") - } - - @Test func `user cancellation cannot delete a claimed command`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "claimed"))) - #expect(await store.claimNextCommand()?.id == "c-1") - - #expect(await store.cancelCommand(id: "c-1") == .missing) - #expect(await store.loadCommands().map(\.status) == [.sending]) - #expect(await store.confirmCommand(id: "c-1") == .updated) - #expect(await store.loadCommands().isEmpty) - } - - @Test func `queued cancellation atomically scrubs and suppresses its cached bubble`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-cancel", text: "cancel me"))) - let staleSnapshot = [ - cacheMessage( - role: "user", - text: "cancel me", - timestamp: 1, - idempotencyKey: "c-cancel:user"), - cacheMessage( - role: "assistant", - text: "newer row", - timestamp: 2, - idempotencyKey: "other-run"), - ] - await store.storeTranscript(sessionKey: "main", messages: staleSnapshot) - - #expect(await store.cancelCommand(id: "c-cancel") == .updated) - #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["newer row"]) - - // An overlapping view can finish a stale whole-transcript write after - // cancellation. The cache owner keeps the canceled UUID suppressed. - await store.storeTranscript(sessionKey: "main", messages: staleSnapshot) - #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["newer row"]) - - // Canonical history can prove an ambiguous send really landed. That - // authoritative row clears suppression and remains cacheable offline. - await store.storeCanonicalTranscript( - sessionKey: "main", - messages: staleSnapshot, - canonicalMessageIdempotencyKeys: ["c-cancel:user"]) - #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["cancel me", "newer row"]) - - #expect(await store.enqueueCommand(outboxCommand(id: "c-canonical-first", text: "already landed"))) - let canonicalFirst = [cacheMessage( - role: "user", - text: "already landed", - timestamp: 3, - idempotencyKey: "c-canonical-first:user")] - await store.storeCanonicalTranscript( - sessionKey: "main", - messages: canonicalFirst, - canonicalMessageIdempotencyKeys: ["c-canonical-first:user"]) - #expect(await store.cancelCommand(id: "c-canonical-first") == .confirmed) - #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["already landed"]) - } - - @Test func `canonical message merge preserves a newer cached snapshot`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - await store.storeTranscript(sessionKey: "main", messages: [ - cacheMessage(role: "assistant", text: "newer row", timestamp: 2, idempotencyKey: "newer-run"), - ]) - - await store.mergeCanonicalTranscriptMessage( - sessionKey: "main", - agentID: nil, - message: cacheMessage( - role: "user", - text: "confirmed row", - timestamp: 1, - idempotencyKey: "confirmed:user"), - canonicalMessageIdempotencyKey: "confirmed:user") - - #expect(await messageTexts(store.loadTranscript(sessionKey: "main")) == ["confirmed row", "newer row"]) - } - - @Test func `scoped cancellation scrubs the canonical transcript partition`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let sessionKey = "agent:agent-a:matrix:channel:!MixedRoomAbCdEf:example.org" - #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( - id: "c-scoped-cancel", - sessionKey: sessionKey, - agentID: "agent-a", - text: "cancel scoped", - thinking: "off", - createdAt: Date().timeIntervalSince1970, - status: .queued, - retryCount: 0, - lastError: nil))) - let staleSnapshot = [cacheMessage( - role: "user", - text: "cancel scoped", - timestamp: 1, - idempotencyKey: "c-scoped-cancel:user")] - await store.storeTranscript(sessionKey: sessionKey, messages: staleSnapshot) - - #expect(await store.cancelCommand(id: "c-scoped-cancel") == .updated) - #expect(await store.loadTranscript(sessionKey: sessionKey).isEmpty) - - await store.storeTranscript(sessionKey: sessionKey, messages: staleSnapshot) - #expect(await store.loadTranscript(sessionKey: sessionKey).isEmpty) - } - - @Test func `cancellation lookup failure preserves the queued command`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-lookup", text: "keep me"))) - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect(await store.cancelCommand(id: "c-lookup") == .unavailable) - - raw = nil - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - #expect(await store.loadCommands().map(\.id) == ["c-lookup"]) - } - - @Test func `transcript lookup failure rolls back queued cancellation`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-cache", text: "keep me"))) - await store.storeTranscript( - sessionKey: "main", - messages: [cacheMessage( - role: "user", - text: "keep me", - timestamp: 1, - idempotencyKey: "c-cache:user")]) - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE cached_transcripts RENAME TO cached_transcripts_unavailable", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect(await store.cancelCommand(id: "c-cache") == .unavailable) - #expect(await store.loadCommands().map(\.id) == ["c-cache"]) - } - - @Test func `interrupted sending rows fail closed on recovery`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - do { - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-1", text: "in flight"))) - #expect(await store.claimNextCommand()?.id == "c-1") - #expect(await store.loadCommands().map(\.status) == [.sending]) - } - - // Simulated crash mid-send: the durable result is ambiguous, so a - // fresh process requires explicit user retry instead of replaying it. - let reopened = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await reopened.recoverInterruptedSends()) - let recovered = await reopened.loadCommands() - #expect(recovered.map(\.status) == [.failed]) - #expect(recovered.map(\.lastError) == [OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError]) - - // An unreachable store must report failure so callers do not burn - // their once-per-launch recovery gate while the DB is locked. - await reopened.retire() - #expect(await !reopened.recoverInterruptedSends()) - } - - @Test func `failed post-claim transition reopens same-process recovery`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-claim", text: "recover me"))) - #expect(await store.recoverInterruptedSends()) - #expect(await store.claimNextCommand()?.id == "c-claim") - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect(await store.markCommandAwaitingConfirmation(id: "c-claim") == .unavailable) - - raw = nil - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect(await store.recoverInterruptedSends()) - #expect(await store.loadCommands().map(\.status) == [.failed]) - } - - @Test func `failed terminal transition reports unavailable and reopens recovery`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-terminal", text: "stop retrying"))) - #expect(await store.recoverInterruptedSends()) - #expect(await store.claimNextCommand()?.id == "c-terminal") - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands RENAME TO outbox_commands_unavailable", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect( - await store.markCommandFailedIfPresent(id: "c-terminal", retryCount: 3, lastError: "rejected") == - .unavailable) - - raw = nil - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - "ALTER TABLE outbox_commands_unavailable RENAME TO outbox_commands", - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect(await store.recoverInterruptedSends()) - let command = await store.loadCommands().first - #expect(command?.status == .failed) - #expect(command?.retryCount == 0) - #expect(command?.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) - } - - @Test func `unknown row status is skipped without blocking later commands`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - #expect(await store.enqueueCommand(outboxCommand(id: "c-valid", text: "send me", createdAt: 2))) - - var raw: OpaquePointer? - #expect(sqlite3_open(url.path, &raw) == SQLITE_OK) - #expect(sqlite3_exec( - raw, - """ - INSERT INTO outbox_commands( - client_uuid, gateway_id, session_key, text, thinking, created_at, status, retry_count, last_error - ) VALUES ('c-unknown', 'gw-a', 'main', 'skip me', 'off', 1, 'future_status', 0, '') - """, - nil, - nil, - nil) == SQLITE_OK) - sqlite3_close_v2(raw) - - #expect(await store.loadCommands().map(\.id) == ["c-valid"]) - } - - @Test func `queued commands expire to failed at the staleness boundary`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let now = Date().timeIntervalSince1970 - let maxAge = OpenClawChatSQLiteTranscriptCache.outboxCommandMaxAge - #expect(await store.enqueueCommand( - outboxCommand(id: "c-stale", text: "stale", createdAt: now - maxAge - 60))) - #expect(await store.enqueueCommand( - outboxCommand(id: "c-fresh", text: "fresh", createdAt: now - maxAge + 60))) - #expect(await store.enqueueCommand( - OpenClawChatOutboxCommand( - id: "c-unconfirmed", - sessionKey: "main", - text: "unconfirmed", - thinking: "off", - createdAt: now - maxAge - 60, - status: .sending, - retryCount: 0, - lastError: nil))) - #expect(await store.markCommandAwaitingConfirmation(id: "c-unconfirmed") == .updated) - - let loaded = await store.loadCommands() - let stale = try #require(loaded.first { $0.id == "c-stale" }) - let fresh = try #require(loaded.first { $0.id == "c-fresh" }) - let unconfirmed = try #require(loaded.first { $0.id == "c-unconfirmed" }) - #expect(stale.status == .failed) - #expect(stale.lastError == OpenClawChatSQLiteTranscriptCache.outboxExpiredError) - #expect(fresh.status == .queued) - #expect(fresh.lastError == nil) - #expect(unconfirmed.status == .failed) - #expect(unconfirmed.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError) - } - - @Test func `enqueue refuses beyond the queue bound`() async throws { - let url = try makeDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-a") - let bound = OpenClawChatSQLiteTranscriptCache.maxQueuedCommands - - for index in 0.. OpenClawChatSQLiteTranscriptCache { - OpenClawChatSQLiteTranscriptCache( - databaseURL: FileManager.default.temporaryDirectory - .appendingPathComponent("attachment-outbox-\(UUID().uuidString).sqlite"), - gatewayID: "attachment-tests") +private func makeAttachmentOutbox() throws -> OpenClawChatSQLiteTranscriptCache { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("attachment-outbox-\(UUID().uuidString)", isDirectory: true) + return try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "attachment-tests") } @MainActor @@ -468,7 +467,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { func testHealthyLegacyGatewayUsesLiveAttachmentPath() async throws { let capture = AttachmentSendCapture() - let outbox = makeAttachmentOutbox() + let outbox = try makeAttachmentOutbox() let viewModel = await MainActor.run { makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport( @@ -507,7 +506,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { func testLegacyGatewayRetainsAttachmentUntilOutboxRestoreCompletes() async throws { let capture = AttachmentSendCapture() - let outbox = makeAttachmentOutbox() + let outbox = try makeAttachmentOutbox() let viewModel = await MainActor.run { let viewModel = makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport( @@ -588,7 +587,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { .appendingPathComponent("voice-note-20260706-120001.m4a") let data = Data("encoded-voice-note".utf8) try data.write(to: fileURL) - let outbox = makeAttachmentOutbox() + let outbox = try makeAttachmentOutbox() let viewModel = await MainActor.run { makeDurableAttachmentViewModel(transport: transport, outbox: outbox) } @@ -659,7 +658,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { } func testAmbiguousVoiceNoteSurvivesViewModelRecreation() async throws { - let outbox = makeAttachmentOutbox() + let outbox = try makeAttachmentOutbox() var firstViewModel: OpenClawChatViewModel? = await MainActor.run { let viewModel = makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport(failsAmbiguously: true), @@ -716,7 +715,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { } func testCanonicalVoiceNoteConfirmationPreservesDurationAndDeletesDurableBytes() async throws { - let outbox = makeAttachmentOutbox() + let outbox = try makeAttachmentOutbox() let viewModel = await MainActor.run { let viewModel = makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport(), diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift index a24df2146f9..f33ff8884d9 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift @@ -1,13 +1,35 @@ import Foundation +import GRDB import OpenClawKit import Testing @testable import OpenClawChatUI -private func makeOutboxDatabaseURL() throws -> URL { +private func makeOutboxDatabaseDirectory() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("chat-outbox-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("chat-cache.sqlite", isDirectory: false) + return dir +} + +private func makeOutboxStore( + databaseDirectoryURL: URL, + gatewayID: String) throws -> OpenClawChatSQLiteTranscriptCache +{ + try OpenClawClientDatabases(directoryURL: databaseDirectoryURL).store(gatewayID: gatewayID) +} + +extension OpenClawChatSQLiteTranscriptCache { + private func storeTestTranscript( + sessionKey: String, + agentID: String? = nil, + messages: [OpenClawChatMessage]) async + { + await self.storeCanonicalTranscript( + sessionKey: sessionKey, + agentID: agentID, + messages: messages, + canonicalMessageIdempotencyKeys: Set(messages.compactMap(\.idempotencyKey))) + } } private func outboxTestCommand(id: String, text: String, createdAt: Double) -> OpenClawChatOutboxCommand { @@ -792,9 +814,11 @@ private actor CancellationHoldingOutbox: OpenClawChatCommandOutbox { struct ChatViewModelOutboxTests { @Test func `offline send queues durably and renders queued row`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) #expect(await MainActor.run { vm.supportsOfflineTextOutbox }) @@ -848,9 +872,11 @@ struct ChatViewModelOutboxTests { } @Test func `unsupported gateway keeps queued work and surfaces upgrade action`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let message = OpenClawChatTransportUpgradeMessage.routingContract let transport = OutboxTestTransport( healthy: false, @@ -869,9 +895,11 @@ struct ChatViewModelOutboxTests { } @Test func `offline queue persists the effective thinking level`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let sessions = [outboxSessionEntry(key: "main", thinkingLevels: ["off"])] let transport = OutboxTestTransport(healthy: false, sessions: sessions) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -889,9 +917,11 @@ struct ChatViewModelOutboxTests { } @Test func `inert outbox does not capability gate healthy live chat`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport( healthy: true, routeUnavailableReason: OpenClawChatTransportUpgradeMessage.routingContract) @@ -920,9 +950,11 @@ struct ChatViewModelOutboxTests { } @Test func `legacy transport preserves its untargeted session key`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false, requiresRoutingContract: false) let vm = await makeOutboxViewModel( transport: transport, @@ -970,9 +1002,11 @@ struct ChatViewModelOutboxTests { } @Test func `reserved unknown session stays unscoped in durable delivery`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel( transport: transport, @@ -996,9 +1030,11 @@ struct ChatViewModelOutboxTests { @Test(arguments: ["global", "main"]) func `mutable alias queued turn keeps its original agent target`(_ sessionKey: String) async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let offlineTransport = OutboxTestTransport(healthy: false) let agentAView = await makeOutboxViewModel( transport: offlineTransport, @@ -1037,9 +1073,11 @@ struct ChatViewModelOutboxTests { } @Test func `reconnect waits for canonical session metadata before flushing Ultra`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: "alpha-ultra", sessionKey: "main", @@ -1082,9 +1120,11 @@ struct ChatViewModelOutboxTests { } @Test func `reconnect retries metadata after a transient session list failure`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(outboxTestCommand( id: "retry-metadata", text: "send after retry", @@ -1110,9 +1150,11 @@ struct ChatViewModelOutboxTests { } @Test func `unscoped opaque peer ID preserves case in its durable target`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let sessionKey = "Matrix:Channel:!MixedRoomAbCdEf:example.org" let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel( @@ -1134,9 +1176,11 @@ struct ChatViewModelOutboxTests { } @Test func `changed default agent parks command visibly for retry`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let oldTransport = OutboxTestTransport(healthy: false) await oldTransport.state.setSessionRoutingContract("per-sender|main|agent-a") let oldView = await makeOutboxViewModel( @@ -1181,9 +1225,11 @@ struct ChatViewModelOutboxTests { } @Test func `atomic gateway routing rejection parks without retrying`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) await MainActor.run { vm.load() } @@ -1201,9 +1247,11 @@ struct ChatViewModelOutboxTests { } @Test func `failed alias row remains reachable after owner change`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: "c-old-failure", sessionKey: "main", @@ -1235,9 +1283,11 @@ struct ChatViewModelOutboxTests { } @Test func `ownerless global retry stays failed without a selected agent`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: "c-ownerless", sessionKey: "global", @@ -1271,9 +1321,11 @@ struct ChatViewModelOutboxTests { } @Test func `unavailable recovery keeps the live send FIFO gate closed`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let outbox = DelayingOutbox(base: store) await outbox.setRecoveryAvailable(false) let transport = OutboxTestTransport(healthy: false) @@ -1286,11 +1338,13 @@ struct ChatViewModelOutboxTests { } @Test func `reconnect flushes queued commands in order with their idempotency keys`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) - // Same store instance backs cache and outbox, like the app wiring. + // One facade routes cache and outbox operations to their separate files. let vm = await makeOutboxViewModel(transport: transport, outbox: store, transcriptCache: store) await MainActor.run { vm.load() } @@ -1318,9 +1372,7 @@ struct ChatViewModelOutboxTests { #expect(await userTexts(vm) == ["first", "second"]) #expect(await MainActor.run { queuedStateCount(vm) } == 0) - // Crash-window durability: the sent turns were written through to the - // transcript cache no later than outbox-row deletion, so a cold - // offline reopen still shows them. + // The refreshed gateway history is cached for cold offline browsing. let cached = await store.loadTranscript(sessionKey: "main", agentID: "main") let cachedUserTexts = cached .filter { $0.role == "user" } @@ -1329,9 +1381,11 @@ struct ChatViewModelOutboxTests { } @Test func `overlapping view models share one atomic FIFO sender`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let now = Date().timeIntervalSince1970 #expect(await store.enqueueCommand(outboxTestCommand(id: "c-1", text: "first", createdAt: now))) #expect(await store.enqueueCommand(outboxTestCommand(id: "c-2", text: "second", createdAt: now + 1))) @@ -1355,9 +1409,11 @@ struct ChatViewModelOutboxTests { } @Test func `assistant reply for a flushed run lands via the external-run final event`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -1399,10 +1455,12 @@ struct ChatViewModelOutboxTests { } } - @Test func `acknowledged turn stays durable until history confirms it`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + @Test func `acknowledged turn stays in client state until history confirms it`() async throws { + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store, transcriptCache: store) @@ -1418,7 +1476,8 @@ struct ChatViewModelOutboxTests { } let cached = await store.loadTranscript(sessionKey: "main", agentID: "main") - #expect(cached.map { $0.content.compactMap(\.text).joined() } == ["must survive"]) + #expect(cached.isEmpty) + #expect(await store.loadCommands().map(\.text) == ["must survive"]) #expect(await MainActor.run { vm.messages.contains { vm.outboxState(for: $0.id) == .confirming } }) @@ -1431,9 +1490,11 @@ struct ChatViewModelOutboxTests { } @Test func `healthy restore reconciles a previously acknowledged turn`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(outboxTestCommand( id: "c-awaiting", text: "already acknowledged", @@ -1458,9 +1519,11 @@ struct ChatViewModelOutboxTests { } @Test func `delete race preserves a turn already proven by canonical history`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(outboxTestCommand( id: "c-delivered", text: "delivered already", @@ -1501,9 +1564,11 @@ struct ChatViewModelOutboxTests { } @Test func `offline local slash command keeps its draft and skips transport`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) await MainActor.run { @@ -1522,9 +1587,11 @@ struct ChatViewModelOutboxTests { } @Test func `lagging history does not recursively refresh an acknowledged turn`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(outboxTestCommand( id: "c-lagging", text: "not persisted yet", @@ -1553,9 +1620,11 @@ struct ChatViewModelOutboxTests { } @Test func `gateway rejections burn attempts then fail terminally and support tap retry`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -1592,9 +1661,11 @@ struct ChatViewModelOutboxTests { } @Test func `unavailable terminal write drops health instead of advancing FIFO`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: "c-terminal-write", sessionKey: "main", @@ -1625,9 +1696,11 @@ struct ChatViewModelOutboxTests { } @Test func `gateway response errors are definitive and burn retry attempts`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -1645,9 +1718,11 @@ struct ChatViewModelOutboxTests { } @Test func `definitive live-send rejection restores draft without queueing`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: true) await transport.state.setSendResponseErrors(true) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -1671,9 +1746,11 @@ struct ChatViewModelOutboxTests { } @Test func `ambiguous live transport failure requires explicit retry`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") // Health reads true, but the actual send path is down: the send gate // is bypassed and the transport error must preserve instead of losing // the optimistic turn. @@ -1727,9 +1804,11 @@ struct ChatViewModelOutboxTests { } @Test func `lost queued send ack reconciles history without replay`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let transport = OutboxTestTransport(healthy: false) let vm = await makeOutboxViewModel(transport: transport, outbox: store) @@ -1749,9 +1828,11 @@ struct ChatViewModelOutboxTests { } @Test func `tap retry refreshes createdAt so an expired command can resend`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") // A command that sat offline past the staleness bound. let staleCreatedAt = Date().timeIntervalSince1970 - OpenClawChatSQLiteTranscriptCache.outboxCommandMaxAge - 60 @@ -1797,9 +1878,11 @@ struct ChatViewModelOutboxTests { } @Test func `flush gates captured thinking using the queued session metadata`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") let now = Date().timeIntervalSince1970 #expect(await store.enqueueCommand( OpenClawChatOutboxCommand( @@ -1846,10 +1929,12 @@ struct ChatViewModelOutboxTests { _ = vm } - @Test func `flushed background-session turn is spliced into its cached transcript`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + @Test func `gateway history caches a flushed background-session turn`() async throws { + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") // Queued for a session the user is no longer viewing. #expect(await store.enqueueCommand( OpenClawChatOutboxCommand( @@ -1871,17 +1956,19 @@ struct ChatViewModelOutboxTests { await store.loadCommands().isEmpty } - // The turn survives in that session's cached transcript even though - // its messages were never loaded into the view model. + // Canonical gateway history, not the optimistic outbox row, owns the + // cached background transcript. let cached = await store.loadTranscript(sessionKey: "other-session") #expect(cached.map { $0.content.compactMap(\.text).joined() } == ["sent from elsewhere"]) #expect(cached.map(\.idempotencyKey) == ["c-background:user"]) } @Test func `background canonical alias event confirms by idempotency key`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") #expect(await store.enqueueCommand(OpenClawChatOutboxCommand( id: "c-alias", sessionKey: "main", @@ -1928,9 +2015,11 @@ struct ChatViewModelOutboxTests { } @Test func `full queue refuses enqueue and keeps the draft`() async throws { - let url = try makeOutboxDatabaseURL() - defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } - let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test") + let databaseDirectory = try makeOutboxDatabaseDirectory() + defer { try? FileManager.default.removeItem(at: databaseDirectory) } + let store = try makeOutboxStore( + databaseDirectoryURL: databaseDirectory, + gatewayID: "gw-test") for index in 0..) async + { self.transcripts[sessionKey] = messages self.storedTranscriptSessionKeys.append(sessionKey) self.storedTranscripts.append(messages) diff --git a/scripts/apple-app-i18n.ts b/scripts/apple-app-i18n.ts index b524fb94821..3832827216e 100644 --- a/scripts/apple-app-i18n.ts +++ b/scripts/apple-app-i18n.ts @@ -943,7 +943,9 @@ export async function checkAppleAppI18n() { } export async function compileMacosLocalizations(outputDir: string) { - await checkAppleAppI18n(); + // Source PRs intentionally leave generated iOS catalogs for the serialized + // post-merge refresh. Packaging only needs the source-owned macOS contract. + await verifyAppleAppI18n(); const catalog = JSON.parse( await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"), ) as Catalog; diff --git a/test/scripts/apple-app-i18n.test.ts b/test/scripts/apple-app-i18n.test.ts index 421ee0bcf4a..d7a64ff46d8 100644 --- a/test/scripts/apple-app-i18n.test.ts +++ b/test/scripts/apple-app-i18n.test.ts @@ -5,17 +5,17 @@ import { describe, expect, it } from "vitest"; import { APPLE_I18N_LOCALES, buildIosCatalog, - checkAppleAppI18n, compileMacosLocalizations, findAmbiguousRuntimeInterpolations, infoPlistTranslationCandidates, selectInfoPlistTranslation, + verifyAppleAppI18n, } from "../../scripts/apple-app-i18n.ts"; import { NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts"; describe("Apple app i18n catalogs", () => { - it("keeps generated runtime coverage complete for every native locale", async () => { - await expect(checkAppleAppI18n()).resolves.toBeUndefined(); + it("keeps source-owned runtime coverage complete", async () => { + await expect(verifyAppleAppI18n()).resolves.toBeUndefined(); }); it("ships translated runtime keys for iOS, watchOS, and explicit localized calls", async () => {