mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
refactor(apple): unify offline client databases (#111598)
* refactor(apple): unify offline client databases * refactor(apple): remove dead cache APIs * fix(apple): satisfy native storage gates * fix(apple): sync generated localization catalog * Revert "fix(apple): sync generated localization catalog" This reverts commit d0f5e7b74932e98ccc5f1984840e8f9d545edc2d. * fix(i18n): keep source validation pre-merge * test(macos): isolate flaky suite state * test(macos): isolate log locator on main actor
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Void, Never>)
|
||||
GatewayStableIdentifier.Key: (id: UUID, task: Task<Bool, Never>)
|
||||
] = [:]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
|
||||
@ObservationIgnored private var clientDatabases: OpenClawClientDatabases?
|
||||
@ObservationIgnored private var chatTranscriptCachesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:]
|
||||
@ObservationIgnored private var chatSessionRoutingRestoreTask: Task<Void, Never>?
|
||||
private var watchExecApprovalPromptsByID: [ExecApprovalIdentifier.Key: ExecApprovalPrompt] = [:]
|
||||
@@ -700,18 +705,29 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private var chatTranscriptCacheGeneration = 0
|
||||
private var quarantinedChatOfflineGatewayIDs: Set<String> = []
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`() {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String>) 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<String>) async
|
||||
{
|
||||
await self.storeTranscript(sessionKey: sessionKey, messages: messages)
|
||||
}
|
||||
|
||||
public func storeCanonicalTranscript(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
messages: [OpenClawChatMessage],
|
||||
canonicalMessageIdempotencyKeys: Set<String>) async
|
||||
{
|
||||
guard agentID == nil else { return }
|
||||
await self.storeCanonicalTranscript(
|
||||
sessionKey: sessionKey,
|
||||
messages: messages,
|
||||
canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys)
|
||||
}
|
||||
|
||||
public func observeCanonicalMessageIdempotencyKeys(_: Set<String>) {}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>? = nil) throws
|
||||
{
|
||||
self.directoryURL = directoryURL
|
||||
self.legacyDirectoryURLs = legacyDirectoryURLs
|
||||
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
|
||||
let stateURL = directoryURL.appendingPathComponent(Self.clientStateFilename, isDirectory: false)
|
||||
self.stateQueue = try Self.openStateDatabase(at: stateURL)
|
||||
self.cacheQueue = try Self.openRepairableCacheDatabase(
|
||||
at: directoryURL.appendingPathComponent(Self.gatewayCacheFilename, isDirectory: false))
|
||||
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<String>? = 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<String>? = 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<String>?) {
|
||||
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<String> {
|
||||
try self.stateQueue.read { db in
|
||||
try Set(String.fetchAll(
|
||||
db,
|
||||
sql: """
|
||||
SELECT gateway_hash FROM forgotten_gateways
|
||||
WHERE cleanup_phase IN (0, 2, 3) OR restore_finalized = 1
|
||||
"""))
|
||||
}
|
||||
}
|
||||
}
|
||||
+1044
-1194
File diff suppressed because it is too large
Load Diff
@@ -140,11 +140,10 @@ private struct AttachmentProcessingTransport: OpenClawChatTransport {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAttachmentOutbox() -> 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(),
|
||||
|
||||
@@ -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..<OpenClawChatSQLiteTranscriptCache.maxQueuedCommands {
|
||||
let accepted = await store.enqueueCommand(
|
||||
OpenClawChatOutboxCommand(
|
||||
@@ -1961,9 +2050,11 @@ struct ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `accepted enqueue after session switch preserves newer original-session 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")
|
||||
let outbox = DelayingOutbox(base: store)
|
||||
let transport = OutboxTestTransport(healthy: false)
|
||||
let vm = await makeOutboxViewModel(transport: transport, outbox: outbox)
|
||||
@@ -2002,9 +2093,11 @@ struct ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `accepted enqueue preserves retyped identical current-session 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")
|
||||
let outbox = DelayingOutbox(base: store)
|
||||
let transport = OutboxTestTransport(healthy: false)
|
||||
let vm = await makeOutboxViewModel(transport: transport, outbox: outbox)
|
||||
@@ -2035,9 +2128,11 @@ struct ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `queued send transport failure fails closed until 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")
|
||||
let transport = OutboxTestTransport(healthy: false, sendFails: true)
|
||||
let vm = await makeOutboxViewModel(transport: transport, outbox: store)
|
||||
|
||||
@@ -2072,9 +2167,10 @@ struct ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `deleting a queued message removes bubble and durable 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 databases = try OpenClawClientDatabases(directoryURL: databaseDirectory)
|
||||
let store = databases.store(gatewayID: "gw-test")
|
||||
let transport = OutboxTestTransport(healthy: false)
|
||||
let vm = await makeOutboxViewModel(transport: transport, outbox: store, transcriptCache: store)
|
||||
|
||||
@@ -2085,18 +2181,33 @@ struct ChatViewModelOutboxTests {
|
||||
vm.messages.first { vm.outboxState(for: $0.id) == .queued }?.id
|
||||
})
|
||||
let commandID = try #require(await store.loadCommands().first?.id)
|
||||
await store.storeTranscript(
|
||||
sessionKey: "main",
|
||||
messages: [OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [OpenClawChatMessageContent(
|
||||
type: "text",
|
||||
text: "changed my mind",
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil)],
|
||||
timestamp: 1,
|
||||
idempotencyKey: "\(commandID):user")])
|
||||
let staleMessage = OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [OpenClawChatMessageContent(
|
||||
type: "text",
|
||||
text: "changed my mind",
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil)],
|
||||
timestamp: 1,
|
||||
idempotencyKey: "\(commandID):user")
|
||||
let stalePayload = try String(decoding: JSONEncoder().encode(staleMessage), as: UTF8.self)
|
||||
try await databases.cacheQueue.write { db in
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO cached_transcripts(gateway_id, session_key, agent_id, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
arguments: ["gw-test", "main", "", 1])
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO cached_messages(
|
||||
gateway_id, session_key, agent_id, position,
|
||||
timestamp_ms, idempotency_key, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
arguments: ["gw-test", "main", "", 0, 1, "\(commandID):user", stalePayload])
|
||||
}
|
||||
await MainActor.run { vm.deleteOutboxMessage(messageID) }
|
||||
|
||||
// The bubble disappears only after the durable delete lands, so a
|
||||
@@ -2143,9 +2254,11 @@ private actor DeleteGate {
|
||||
|
||||
extension ChatViewModelOutboxTests {
|
||||
@Test func `double submit during the offline health probe enqueues once`() 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)
|
||||
|
||||
@@ -2172,9 +2285,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `double submit during slash validation enqueues once`() 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, supportsSlashCommands: true)
|
||||
let commandListGate = DeleteGate()
|
||||
await transport.state.setCommandListGate(commandListGate)
|
||||
@@ -2199,10 +2314,12 @@ extension ChatViewModelOutboxTests {
|
||||
#expect(await MainActor.run { vm.input } == "newer draft")
|
||||
}
|
||||
|
||||
@Test func `stale history after the flush ack cannot evict the sent turn from the cache`() async throws {
|
||||
let url = try makeOutboxDatabaseURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = OpenClawChatSQLiteTranscriptCache(databaseURL: url, gatewayID: "gw-test")
|
||||
@Test func `stale history after the flush ack keeps the durable turn visible`() 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)
|
||||
|
||||
@@ -2226,16 +2343,21 @@ extension ChatViewModelOutboxTests {
|
||||
message.content.contains { $0.text == "older turn" }
|
||||
} }
|
||||
}
|
||||
// Wait out the chained cache writes, then cold-reopen offline: the
|
||||
// sent turn must still pre-paint while the durable confirmation row
|
||||
// protects it from the lagging snapshot.
|
||||
// The cache remains a pure gateway snapshot. The durable confirmation
|
||||
// row is merged into the live view instead of being copied into it.
|
||||
if let pendingWrite = await MainActor.run(body: { vm.pendingCacheWriteTask }) {
|
||||
await pendingWrite.value
|
||||
}
|
||||
let cached = await store.loadTranscript(sessionKey: "main", agentID: "main")
|
||||
#expect(cached.contains { message in
|
||||
#expect(!cached.contains { message in
|
||||
message.content.contains { $0.text == "must survive stale history" }
|
||||
})
|
||||
#expect(await MainActor.run {
|
||||
vm.messages.contains { message in
|
||||
message.content.contains { $0.text == "must survive stale history" } &&
|
||||
vm.outboxState(for: message.id) == .confirming
|
||||
}
|
||||
})
|
||||
await transport.state.setStaleHistoryRows(nil)
|
||||
await MainActor.run { vm.refresh() }
|
||||
try await waitUntil("fresh history confirms send") {
|
||||
@@ -2244,9 +2366,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `send before restore adopts durable rows still queues behind them`() 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")
|
||||
// Persist a row as an earlier process would have.
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: UUID().uuidString,
|
||||
@@ -2283,9 +2407,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `send right after a session switch still queues behind that session's backlog`() 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")
|
||||
// Backlog persisted for a session that is not initially visible.
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: UUID().uuidString,
|
||||
@@ -2337,9 +2463,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `flush waits for an in-flight model patch before sending`() 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)
|
||||
|
||||
@@ -2363,9 +2491,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `offline enqueue waits for a truly in-flight model patch`() 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)
|
||||
|
||||
@@ -2387,9 +2517,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `live send after reconnect queues behind draining outbox rows`() 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)
|
||||
|
||||
@@ -2425,9 +2557,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `a stale second view model cannot cancel a claimed send`() 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 sender = await makeOutboxViewModel(transport: transport, outbox: store)
|
||||
|
||||
@@ -2462,9 +2596,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `both view models remove a command canceled by either one`() 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 first = await makeOutboxViewModel(transport: transport, outbox: store)
|
||||
|
||||
@@ -2493,9 +2629,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `cancellation invalidates another views in flight restore snapshot`() 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 cancelingView = await makeOutboxViewModel(transport: transport, outbox: store)
|
||||
|
||||
@@ -2534,9 +2672,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `confirmation invalidates cancellation claimed row reload`() 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 = SnapshotHoldingOutbox(base: store)
|
||||
let transport = OutboxTestTransport(healthy: false)
|
||||
let vm = await MainActor.run {
|
||||
@@ -2565,9 +2705,11 @@ extension ChatViewModelOutboxTests {
|
||||
}
|
||||
|
||||
@Test func `route replacement cannot retarget a claimed command`() 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)
|
||||
|
||||
|
||||
+6
-1
@@ -85,7 +85,12 @@ private actor TestTranscriptCache: OpenClawChatTranscriptCache {
|
||||
self.storedSessionsCallCount += 1
|
||||
}
|
||||
|
||||
func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async {
|
||||
func storeCanonicalTranscript(
|
||||
sessionKey: String,
|
||||
agentID _: String?,
|
||||
messages: [OpenClawChatMessage],
|
||||
canonicalMessageIdempotencyKeys _: Set<String>) async
|
||||
{
|
||||
self.transcripts[sessionKey] = messages
|
||||
self.storedTranscriptSessionKeys.append(sessionKey)
|
||||
self.storedTranscripts.append(messages)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user