mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
refactor(android): split offline client databases (#111710)
* refactor(android): split offline client databases * chore(android): refresh native i18n inventory * test(gateway): close databases before transcript cleanup
This commit is contained in:
+126
-126
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,10 @@ plugins {
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
// AndroidX Core 1.19 and Lifecycle 2.11 require API 37 compilation.
|
||||
@@ -327,7 +331,7 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.androidx.security.crypto)
|
||||
// Room owns the disposable transcript cache and durable chat outbox; migrations preserve outbox rows.
|
||||
// Room owns separate disposable gateway cache and durable client-state databases.
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "924cec9afdb455dced2592399a08f5da",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "outbox_commands",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, `gatedEpoch` INTEGER, `ownerAgentId` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "gatewayId",
|
||||
"columnName": "gatewayId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionKey",
|
||||
"columnName": "sessionKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "thinkingLevel",
|
||||
"columnName": "thinkingLevel",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAtMs",
|
||||
"columnName": "createdAtMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retryCount",
|
||||
"columnName": "retryCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "gatedEpoch",
|
||||
"columnName": "gatedEpoch",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerAgentId",
|
||||
"columnName": "ownerAgentId",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "outbox_attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, `position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, `durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "commandId",
|
||||
"columnName": "commandId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileName",
|
||||
"columnName": "fileName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationMs",
|
||||
"columnName": "durationMs",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "byteLength",
|
||||
"columnName": "byteLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_outbox_attachments_commandId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"commandId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `${TABLE_NAME}` (`commandId`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox_attachment_chunks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attachmentId` TEXT NOT NULL, `chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "attachmentId",
|
||||
"columnName": "attachmentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "chunkIndex",
|
||||
"columnName": "chunkIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bytes",
|
||||
"columnName": "bytes",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"attachmentId",
|
||||
"chunkIndex"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "composer_send_admissions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "gatewayId",
|
||||
"columnName": "gatewayId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerAgentId",
|
||||
"columnName": "ownerAgentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionKey",
|
||||
"columnName": "sessionKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "client_state_metadata",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "gateway_removals",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gatewayId",
|
||||
"columnName": "gatewayId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "phase",
|
||||
"columnName": "phase",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"gatewayId"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '924cec9afdb455dced2592399a08f5da')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "b40806fd03e11cc1094f31253e9432fb",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "cached_sessions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gatewayId",
|
||||
"columnName": "gatewayId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "agentId",
|
||||
"columnName": "agentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionKey",
|
||||
"columnName": "sessionKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMs",
|
||||
"columnName": "updatedAtMs",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rowOrder",
|
||||
"columnName": "rowOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"gatewayId",
|
||||
"agentId",
|
||||
"sessionKey"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gatewayId",
|
||||
"columnName": "gatewayId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "agentId",
|
||||
"columnName": "agentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionKey",
|
||||
"columnName": "sessionKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rowOrder",
|
||||
"columnName": "rowOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "textPartsJson",
|
||||
"columnName": "textPartsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMs",
|
||||
"columnName": "timestampMs",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "idempotencyKey",
|
||||
"columnName": "idempotencyKey",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"gatewayId",
|
||||
"agentId",
|
||||
"sessionKey",
|
||||
"rowOrder"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_gateway_owners",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gatewayId",
|
||||
"columnName": "gatewayId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "agentId",
|
||||
"columnName": "agentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"gatewayId"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b40806fd03e11cc1094f31253e9432fb')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.chat.ChatCacheDatabase
|
||||
import ai.openclaw.app.chat.RoomChatCommandOutbox
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.i18n.NativeStringResources
|
||||
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
|
||||
import ai.openclaw.app.wear.GoogleWearMessageSender
|
||||
@@ -13,12 +9,10 @@ import ai.openclaw.app.wear.WearRealtimeChannelRegistry
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
import android.os.StrictMode
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
@@ -91,38 +85,12 @@ class NodeApp : Application() {
|
||||
suspend fun resetGatewaySetupAuth(stableId: String): Boolean {
|
||||
val runtime =
|
||||
synchronized(runtimeLock) {
|
||||
runtimeInstance?.let { return@synchronized it }
|
||||
// Keep runtime construction blocked through the direct purge: a runtime built from the old
|
||||
// credentials could otherwise reconnect and rewrite device auth after this reset returns.
|
||||
return runCatching { resetGatewaySetupAuthBeforeRuntime(stableId) }.getOrDefault(false)
|
||||
runtimeInstance
|
||||
?: NodeRuntime.forGatewayAuthReset(this, prefs).also { runtimeInstance = it }
|
||||
}
|
||||
return runtime.resetGatewaySetupAuth(stableId)
|
||||
}
|
||||
|
||||
private fun resetGatewaySetupAuthBeforeRuntime(stableId: String): Boolean {
|
||||
val gatewayId = stableId.trim().takeIf { it.isNotEmpty() } ?: return false
|
||||
val database = ChatCacheDatabase.open(this)
|
||||
try {
|
||||
runBlocking {
|
||||
database.withTransaction {
|
||||
database.dao().deleteMessages(gatewayId)
|
||||
database.dao().deleteSessionsForGateway(gatewayId)
|
||||
database.dao().deleteGatewayOwner(gatewayId)
|
||||
// The outbox owns command/attachment cascade deletes; nested transactions join this one.
|
||||
RoomChatCommandOutbox(database).clearGateway(gatewayId)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
prefs.clearGatewayCredentials(gatewayId)
|
||||
val deviceId = DeviceIdentityStore.withPrefs(this, prefs).loadOrCreate().deviceId
|
||||
val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
deviceAuthStore.clearToken(gatewayId, deviceId, "node")
|
||||
deviceAuthStore.clearToken(gatewayId, deviceId, "operator")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NativeStringResources.install(this)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.chat.AndroidClientDatabases
|
||||
import ai.openclaw.app.chat.BackgroundTask
|
||||
import ai.openclaw.app.chat.ChatCacheDatabase
|
||||
import ai.openclaw.app.chat.ChatCacheScope
|
||||
import ai.openclaw.app.chat.ChatCommandEntry
|
||||
import ai.openclaw.app.chat.ChatCommandOutbox
|
||||
@@ -26,8 +26,6 @@ import ai.openclaw.app.chat.MessageSpeechClient
|
||||
import ai.openclaw.app.chat.MessageSpeechController
|
||||
import ai.openclaw.app.chat.MessageSpeechState
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.chat.RoomChatCommandOutbox
|
||||
import ai.openclaw.app.chat.RoomChatTranscriptCache
|
||||
import ai.openclaw.app.chat.SystemSpeechSpeaker
|
||||
import ai.openclaw.app.gateway.DeviceAuthEntry
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
@@ -597,6 +595,8 @@ internal fun gatewayConnectionDisplay(
|
||||
private data class AndroidChatStores(
|
||||
val transcriptCache: ChatTranscriptCache,
|
||||
val commandOutbox: ChatCommandOutbox,
|
||||
val clientDatabases: AndroidClientDatabases,
|
||||
val externalTranscriptCache: ChatTranscriptCache? = null,
|
||||
)
|
||||
|
||||
internal enum class NodeRuntimeMode {
|
||||
@@ -604,11 +604,43 @@ internal enum class NodeRuntimeMode {
|
||||
ScreenshotFixture,
|
||||
}
|
||||
|
||||
private fun openAndroidChatStores(context: Context): AndroidChatStores {
|
||||
val database = ChatCacheDatabase.open(context.applicationContext)
|
||||
private fun openAndroidChatStores(
|
||||
context: Context,
|
||||
prefs: SecurePrefs,
|
||||
): AndroidChatStores {
|
||||
val databases =
|
||||
AndroidClientDatabases.start(
|
||||
context.applicationContext,
|
||||
registeredGatewayIds =
|
||||
prefs.gatewayRegistry.entries.value
|
||||
.map { it.stableId }
|
||||
.toSet(),
|
||||
)
|
||||
return AndroidChatStores(
|
||||
transcriptCache = RoomChatTranscriptCache(database),
|
||||
commandOutbox = RoomChatCommandOutbox(database),
|
||||
transcriptCache = databases.transcriptCache(),
|
||||
commandOutbox = databases.commandOutbox(),
|
||||
clientDatabases = databases,
|
||||
)
|
||||
}
|
||||
|
||||
private fun openAndroidChatStores(
|
||||
context: Context,
|
||||
prefs: SecurePrefs,
|
||||
transcriptCache: ChatTranscriptCache,
|
||||
): AndroidChatStores {
|
||||
val databases =
|
||||
AndroidClientDatabases.start(
|
||||
context.applicationContext,
|
||||
registeredGatewayIds =
|
||||
prefs.gatewayRegistry.entries.value
|
||||
.map { it.stableId }
|
||||
.toSet(),
|
||||
)
|
||||
return AndroidChatStores(
|
||||
transcriptCache = transcriptCache,
|
||||
commandOutbox = databases.commandOutbox(),
|
||||
clientDatabases = databases,
|
||||
externalTranscriptCache = transcriptCache,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -619,9 +651,12 @@ class NodeRuntime private constructor(
|
||||
chatStores: AndroidChatStores,
|
||||
internal val mode: NodeRuntimeMode,
|
||||
initialForeground: Boolean,
|
||||
initialReconnectSuppressed: Boolean,
|
||||
) {
|
||||
private val chatTranscriptCache = chatStores.transcriptCache
|
||||
private val chatCommandOutbox = chatStores.commandOutbox
|
||||
private val clientDatabases = chatStores.clientDatabases
|
||||
private val externalTranscriptCache = chatStores.externalTranscriptCache
|
||||
private val gatewayAuthLifecycleLock = Any()
|
||||
private var gatewayAuthResetInProgress = false
|
||||
private var gatewayConnectOperationsInFlight = 0
|
||||
@@ -671,9 +706,10 @@ class NodeRuntime private constructor(
|
||||
context = context,
|
||||
prefs = prefs,
|
||||
tlsFingerprintProbe = tlsFingerprintProbe,
|
||||
chatStores = openAndroidChatStores(context),
|
||||
chatStores = openAndroidChatStores(context, prefs),
|
||||
mode = NodeRuntimeMode.Live,
|
||||
initialForeground = true,
|
||||
initialReconnectSuppressed = false,
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
@@ -684,9 +720,10 @@ class NodeRuntime private constructor(
|
||||
context = context,
|
||||
prefs = prefs,
|
||||
tlsFingerprintProbe = ::probeGatewayTlsFingerprint,
|
||||
chatStores = openAndroidChatStores(context),
|
||||
chatStores = openAndroidChatStores(context, prefs),
|
||||
mode = NodeRuntimeMode.Live,
|
||||
initialForeground = initialForeground,
|
||||
initialReconnectSuppressed = false,
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
@@ -697,9 +734,10 @@ class NodeRuntime private constructor(
|
||||
context = context,
|
||||
prefs = prefs,
|
||||
tlsFingerprintProbe = ::probeGatewayTlsFingerprint,
|
||||
chatStores = openAndroidChatStores(context),
|
||||
chatStores = openAndroidChatStores(context, prefs),
|
||||
mode = mode,
|
||||
initialForeground = true,
|
||||
initialReconnectSuppressed = false,
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
@@ -710,15 +748,28 @@ class NodeRuntime private constructor(
|
||||
context = context,
|
||||
prefs = prefs,
|
||||
tlsFingerprintProbe = ::probeGatewayTlsFingerprint,
|
||||
chatStores =
|
||||
AndroidChatStores(
|
||||
transcriptCache = chatTranscriptCache,
|
||||
commandOutbox = RoomChatCommandOutbox(ChatCacheDatabase.open(context.applicationContext)),
|
||||
),
|
||||
chatStores = openAndroidChatStores(context, prefs, chatTranscriptCache),
|
||||
mode = NodeRuntimeMode.Live,
|
||||
initialForeground = true,
|
||||
initialReconnectSuppressed = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal fun forGatewayAuthReset(
|
||||
context: Context,
|
||||
prefs: SecurePrefs,
|
||||
): NodeRuntime =
|
||||
NodeRuntime(
|
||||
context = context,
|
||||
prefs = prefs,
|
||||
tlsFingerprintProbe = ::probeGatewayTlsFingerprint,
|
||||
chatStores = openAndroidChatStores(context, prefs),
|
||||
mode = NodeRuntimeMode.Live,
|
||||
initialForeground = true,
|
||||
initialReconnectSuppressed = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication material supplied by setup/manual connect flows before gateway session routing.
|
||||
*/
|
||||
@@ -2607,11 +2658,15 @@ class NodeRuntime private constructor(
|
||||
// Replacing authentication retires the old identity even when the endpoint is unchanged.
|
||||
// Purge only that gateway; ordinary switches retain every gateway's offline state.
|
||||
val cacheCleared =
|
||||
runCatching { chat.clearGatewayCache(stableId) }
|
||||
.onFailure { err ->
|
||||
Log.e("OpenClawRuntime", "Failed to purge gateway chat data before auth reset", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't clear offline chat data. Retry sign out.")
|
||||
}.isSuccess
|
||||
runCatching {
|
||||
chat.clearGatewayCache(stableId) {
|
||||
clientDatabases.commitGatewayRemoval(stableId, requireCacheRemoval = true)
|
||||
externalTranscriptCache?.clearGateway(stableId)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
Log.e("OpenClawRuntime", "Failed to purge gateway chat data before auth reset", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't clear offline chat data. Retry sign out.")
|
||||
}.isSuccess
|
||||
if (!cacheCleared) return false
|
||||
prefs.clearGatewayCredentials(stableId)
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
@@ -2645,7 +2700,7 @@ class NodeRuntime private constructor(
|
||||
|
||||
private var didAutoConnect = false
|
||||
|
||||
@Volatile private var preferredGatewayReconnectSuppressed = false
|
||||
@Volatile private var preferredGatewayReconnectSuppressed = initialReconnectSuppressed
|
||||
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionOwnerAgentId: StateFlow<String?> = chat.sessionOwnerAgentId
|
||||
@@ -4392,21 +4447,54 @@ class NodeRuntime private constructor(
|
||||
prepareDisconnect(retireRunState = true)
|
||||
}
|
||||
drainIdleGatewaySessionTails()
|
||||
val cacheCleared =
|
||||
runCatching { chat.clearGatewayCache(normalized) }
|
||||
val removalStaged =
|
||||
runCatching { clientDatabases.stageGatewayRemoval(normalized) }
|
||||
.onFailure { err ->
|
||||
Log.e("OpenClawRuntime", "Failed to purge forgotten gateway chat data", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't clear offline gateway data. Retry forget.")
|
||||
Log.e("OpenClawRuntime", "Failed to stage forgotten gateway data removal", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't prepare offline gateway cleanup. Retry forget.")
|
||||
}.isSuccess
|
||||
if (!removalStaged) return false
|
||||
val authRetired =
|
||||
runCatching {
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
deviceAuthStore.clearToken(normalized, deviceId, "node")
|
||||
deviceAuthStore.clearToken(normalized, deviceId, "operator")
|
||||
prefs.clearGatewayCredentials(normalized)
|
||||
prefs.clearGatewayCustomHeaders(normalized)
|
||||
prefs.clearGatewayTlsFingerprint(normalized)
|
||||
prefs.clearNotificationForwardingSessionKey(normalized)
|
||||
}.onFailure { err ->
|
||||
runCatching { clientDatabases.cancelGatewayRemoval(normalized) }
|
||||
Log.e("OpenClawRuntime", "Failed to retire forgotten gateway authentication", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't clear saved gateway authentication. Retry forget.")
|
||||
}.isSuccess
|
||||
if (!authRetired) return false
|
||||
val cacheCleared =
|
||||
runCatching {
|
||||
chat.clearGatewayCache(normalized) {
|
||||
clientDatabases.commitGatewayRemoval(normalized, requireCacheRemoval = true)
|
||||
externalTranscriptCache?.clearGateway(normalized)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
Log.e("OpenClawRuntime", "Failed to purge forgotten gateway chat data", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't clear offline gateway data. Retry forget.")
|
||||
}.isSuccess
|
||||
if (!cacheCleared) return false
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
deviceAuthStore.clearToken(normalized, deviceId, "node")
|
||||
deviceAuthStore.clearToken(normalized, deviceId, "operator")
|
||||
prefs.clearGatewayCredentials(normalized)
|
||||
prefs.clearGatewayCustomHeaders(normalized)
|
||||
prefs.clearGatewayTlsFingerprint(normalized)
|
||||
prefs.clearNotificationForwardingSessionKey(normalized)
|
||||
prefs.gatewayRegistry.remove(normalized)
|
||||
// Publish registry removal only after auth, durable state, and transcripts are gone. Any
|
||||
// earlier failure leaves this signed-out registration available for an idempotent retry.
|
||||
val registryRemoved =
|
||||
runCatching {
|
||||
check(prefs.gatewayRegistry.remove(normalized)) { "Failed to persist gateway registry removal" }
|
||||
}.fold(
|
||||
onSuccess = { true },
|
||||
onFailure = { err ->
|
||||
runCatching { clientDatabases.cancelGatewayRemoval(normalized) }
|
||||
Log.e("OpenClawRuntime", "Failed to commit forgotten gateway registry removal", err)
|
||||
setStandaloneGatewayStatus("Failed: couldn't remove the saved gateway. Retry forget.")
|
||||
false
|
||||
},
|
||||
)
|
||||
if (!registryRemoved) return false
|
||||
true
|
||||
} finally {
|
||||
synchronized(gatewayAuthLifecycleLock) { gatewayAuthResetInProgress = false }
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.withTransaction
|
||||
@@ -290,6 +291,41 @@ internal class OutboxAttachmentChunkEntity(
|
||||
|
||||
@Dao
|
||||
internal interface ChatOutboxDao {
|
||||
// One-time legacy import reads small metadata sets plus bounded BLOB pages before copying them
|
||||
// into client-state. Runtime callers remain gateway-scoped below.
|
||||
@Query("SELECT * FROM outbox_commands ORDER BY gatewayId ASC, createdAtMs ASC, id ASC")
|
||||
suspend fun allCommands(): List<OutboxCommandEntity>
|
||||
|
||||
@Query("SELECT * FROM composer_send_admissions ORDER BY gatewayId ASC, ownerAgentId ASC, id ASC")
|
||||
suspend fun allAdmissionReceipts(): List<ComposerSendAdmissionEntity>
|
||||
|
||||
@Query("SELECT * FROM outbox_attachments ORDER BY commandId ASC, position ASC")
|
||||
suspend fun allAttachments(): List<OutboxAttachmentEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM outbox_attachment_chunks " +
|
||||
"WHERE :afterAttachmentId IS NULL OR attachmentId > :afterAttachmentId " +
|
||||
"OR (attachmentId = :afterAttachmentId AND chunkIndex > :afterChunkIndex) " +
|
||||
"ORDER BY attachmentId ASC, chunkIndex ASC LIMIT :limit",
|
||||
)
|
||||
suspend fun attachmentChunkPage(
|
||||
afterAttachmentId: String?,
|
||||
afterChunkIndex: Int,
|
||||
limit: Int,
|
||||
): List<OutboxAttachmentChunkEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertImportedCommands(rows: List<OutboxCommandEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertImportedAdmissionReceipts(rows: List<ComposerSendAdmissionEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertImportedAttachments(rows: List<OutboxAttachmentEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertImportedAttachmentChunks(rows: List<OutboxAttachmentChunkEntity>)
|
||||
|
||||
// id tiebreak keeps flush order deterministic when two rows share a createdAt millisecond.
|
||||
@Query("SELECT * FROM outbox_commands WHERE gatewayId = :gatewayId ORDER BY createdAtMs ASC, id ASC")
|
||||
suspend fun commands(gatewayId: String): List<OutboxCommandEntity>
|
||||
@@ -458,13 +494,13 @@ internal interface ChatOutboxDao {
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-backed [ChatCommandOutbox] sharing the chat cache database. Callers pass the gateway id
|
||||
* captured before their suspend point; a blank identity disables both reads and writes.
|
||||
* Room-backed [ChatCommandOutbox] in the durable client-state database. Callers pass the gateway
|
||||
* id captured before their suspend point; a blank identity disables both reads and writes.
|
||||
* Command rows and their attachment bytes are admitted and retired in single transactions, so
|
||||
* a crash can never orphan bytes or strand a row without its attachments.
|
||||
*/
|
||||
class RoomChatCommandOutbox internal constructor(
|
||||
private val database: ChatCacheDatabase,
|
||||
private val database: ClientStateDatabase,
|
||||
) : ChatCommandOutbox {
|
||||
override suspend fun load(gatewayId: String): List<ChatOutboxItem> {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
|
||||
|
||||
@@ -602,6 +602,17 @@ class ChatController internal constructor(
|
||||
|
||||
/** Purges cached transcripts and queued sends for one retired authentication scope. */
|
||||
internal suspend fun clearGatewayCache(gatewayId: String) {
|
||||
clearGatewayCache(gatewayId) { gateway ->
|
||||
transcriptCache?.clearGateway(gateway)
|
||||
commandOutbox?.clearGateway(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializes an owner-provided cross-store purge with every chat cache/outbox mutation. */
|
||||
internal suspend fun clearGatewayCache(
|
||||
gatewayId: String,
|
||||
clearStores: suspend (String) -> Unit,
|
||||
) {
|
||||
val gateway = gatewayId.trim().takeIf { it.isNotEmpty() } ?: return
|
||||
synchronized(defaultAgentPersistenceRevisions) {
|
||||
defaultAgentPersistenceRevisions[gateway] = (defaultAgentPersistenceRevisions[gateway] ?: 0L) + 1L
|
||||
@@ -615,8 +626,7 @@ class ChatController internal constructor(
|
||||
// deleted here; queued old-owner saves then fail their revision check after this unlocks.
|
||||
defaultAgentPersistenceMutex.withLock {
|
||||
cacheMutationMutex.withLock {
|
||||
transcriptCache?.clearGateway(gateway)
|
||||
commandOutbox?.clearGateway(gateway)
|
||||
clearStores(gateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.withTransaction
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -20,8 +14,6 @@ import java.util.UUID
|
||||
/** Upper bound of cached session rows per gateway across every agent owner. */
|
||||
internal const val MAX_CACHED_SESSIONS = 50
|
||||
|
||||
internal const val CHAT_TRANSCRIPT_CACHE_DB_NAME = "chat-transcript-cache.db"
|
||||
|
||||
/** Upper bound of cached transcript rows per session; only the newest messages are kept. */
|
||||
internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200
|
||||
|
||||
@@ -226,165 +218,12 @@ internal interface ChatCacheDao {
|
||||
suspend fun evictGatewayOrphanedTranscripts(gatewayId: String)
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
CachedSessionEntity::class,
|
||||
CachedMessageEntity::class,
|
||||
OutboxCommandEntity::class,
|
||||
OutboxAttachmentEntity::class,
|
||||
OutboxAttachmentChunkEntity::class,
|
||||
ComposerSendAdmissionEntity::class,
|
||||
CachedGatewayOwnerEntity::class,
|
||||
],
|
||||
version = 8,
|
||||
exportSchema = false,
|
||||
)
|
||||
internal abstract class ChatCacheDatabase : RoomDatabase() {
|
||||
abstract fun dao(): ChatCacheDao
|
||||
|
||||
abstract fun outboxDao(): ChatOutboxDao
|
||||
|
||||
companion object {
|
||||
internal val MIGRATION_2_3 =
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// v2 persisted every post-dispatch exception as queued+lastError. Those rows may
|
||||
// already have run, so upgrading must park them alongside crash-interrupted sends.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET status = ?, lastError = ? " +
|
||||
"WHERE status = ? OR (status = ? AND lastError IS NOT NULL)",
|
||||
arrayOf<Any?>(
|
||||
ChatOutboxStatus.Failed.dbValue,
|
||||
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
|
||||
ChatOutboxStatus.Sending.dbValue,
|
||||
ChatOutboxStatus.Queued.dbValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_3_4 =
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER")
|
||||
// Legacy queued command-shaped rows predate connection epochs; the sentinel makes
|
||||
// them park for explicit retry instead of silently replaying on the next reconnect.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'",
|
||||
arrayOf<Any?>(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue),
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " +
|
||||
"`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " +
|
||||
"`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " +
|
||||
"`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_4_5 =
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT")
|
||||
// Agent-qualified keys carry a durable owner in the key itself. Backfill it so session
|
||||
// deletion and replay keep working after upgrade without consulting mutable defaults.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET ownerAgentId = " +
|
||||
"substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " +
|
||||
"WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1",
|
||||
)
|
||||
// Earlier rows did not persist the default agent that owned an unscoped key. Never
|
||||
// guess after upgrade: queued input stays visible for manual resend, while accepted
|
||||
// input remains delivery-ambiguous and must not be replayed under a different owner.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET status = ?, lastError = ? " +
|
||||
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
|
||||
arrayOf<Any?>(
|
||||
ChatOutboxStatus.Failed.dbValue,
|
||||
OUTBOX_OWNER_CHANGED_ERROR,
|
||||
ChatOutboxStatus.Queued.dbValue,
|
||||
),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET status = ?, lastError = ? " +
|
||||
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
|
||||
arrayOf<Any?>(
|
||||
ChatOutboxStatus.Failed.dbValue,
|
||||
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
|
||||
ChatOutboxStatus.Accepted.dbValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_5_6 =
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Session and transcript caches are disposable, and legacy unscoped rows have no
|
||||
// provable owner. Rebuild both; the durable outbox remains intact across the upgrade.
|
||||
db.execSQL("DROP TABLE IF EXISTS `cached_sessions`")
|
||||
db.execSQL("DROP TABLE IF EXISTS `cached_messages`")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
|
||||
"`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " +
|
||||
"`timestampMs` INTEGER, `idempotencyKey` TEXT, " +
|
||||
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_6_7 =
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_7_8 =
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `composer_send_admissions` " +
|
||||
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " +
|
||||
"`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun open(
|
||||
context: Context,
|
||||
name: String = CHAT_TRANSCRIPT_CACHE_DB_NAME,
|
||||
): ChatCacheDatabase =
|
||||
Room
|
||||
.databaseBuilder(context, ChatCacheDatabase::class.java, name)
|
||||
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
// v1 has only disposable transcripts. Starting with v2, the outbox is user data, so every
|
||||
// supported bump needs an explicit migration; destructive fallback remains for v1 only.
|
||||
.fallbackToDestructiveMigrationFrom(true, 1)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-backed [ChatTranscriptCache]. Callers bind every operation to the gateway scope captured
|
||||
* before their suspend point, so a connection switch cannot re-scope an old response.
|
||||
*/
|
||||
class RoomChatTranscriptCache internal constructor(
|
||||
private val database: ChatCacheDatabase,
|
||||
private val database: GatewayCacheDatabase,
|
||||
) : ChatTranscriptCache {
|
||||
private val json = Json
|
||||
private val textPartsSerializer = ListSerializer(String.serializer())
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.withTransaction
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
internal const val GATEWAY_CACHE_DB_NAME = "gateway-cache.db"
|
||||
internal const val CLIENT_STATE_DB_NAME = "client-state.db"
|
||||
internal const val LEGACY_CHAT_DATABASE_NAME = "chat-transcript-cache.db"
|
||||
|
||||
private const val LEGACY_IMPORT_KEY = "legacy-chat-transcript-cache-v8"
|
||||
private const val LEGACY_IMPORT_COMPLETE = "complete"
|
||||
private const val LEGACY_IMPORT_CHUNK_PAGE_ROWS = 8
|
||||
private const val GATEWAY_REMOVAL_STAGED = "staged"
|
||||
private const val GATEWAY_REMOVAL_COMMITTING = "committing"
|
||||
private const val GATEWAY_REMOVAL_CACHE_PENDING = "cache-pending"
|
||||
|
||||
@Entity(tableName = "client_state_metadata")
|
||||
internal data class ClientStateMetadataEntity(
|
||||
@PrimaryKey val key: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "gateway_removals")
|
||||
internal data class GatewayRemovalEntity(
|
||||
@PrimaryKey val gatewayId: String,
|
||||
val phase: String,
|
||||
)
|
||||
|
||||
@Dao
|
||||
internal interface ClientStateControlDao {
|
||||
@Query("SELECT value FROM client_state_metadata WHERE `key` = :key")
|
||||
suspend fun metadataValue(key: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertMetadata(row: ClientStateMetadataEntity)
|
||||
|
||||
@Query("SELECT * FROM gateway_removals ORDER BY gatewayId ASC")
|
||||
suspend fun gatewayRemovals(): List<GatewayRemovalEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertGatewayRemoval(row: GatewayRemovalEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertGatewayRemovalIfAbsent(row: GatewayRemovalEntity)
|
||||
|
||||
@Query("DELETE FROM gateway_removals WHERE gatewayId = :gatewayId AND phase = :phase")
|
||||
suspend fun deleteGatewayRemovalInPhase(
|
||||
gatewayId: String,
|
||||
phase: String,
|
||||
)
|
||||
}
|
||||
|
||||
/** Disposable gateway-derived projections. Schema mismatches and corruption rebuild this file. */
|
||||
@Database(
|
||||
entities = [CachedSessionEntity::class, CachedMessageEntity::class, CachedGatewayOwnerEntity::class],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
internal abstract class GatewayCacheDatabase : RoomDatabase() {
|
||||
abstract fun dao(): ChatCacheDao
|
||||
|
||||
companion object {
|
||||
fun open(
|
||||
context: Context,
|
||||
name: String = GATEWAY_CACHE_DB_NAME,
|
||||
): GatewayCacheDatabase {
|
||||
val appContext = context.applicationContext
|
||||
|
||||
fun build(): GatewayCacheDatabase =
|
||||
Room
|
||||
.databaseBuilder(appContext, GatewayCacheDatabase::class.java, name)
|
||||
// Cache rows are gateway-owned projections. A missing migration means rebuild, and
|
||||
// dropAllTables also removes obsolete cache tables left by older formats.
|
||||
.fallbackToDestructiveMigration(true)
|
||||
.build()
|
||||
|
||||
var database: GatewayCacheDatabase? = null
|
||||
return try {
|
||||
build().also {
|
||||
database = it
|
||||
// Room opens lazily; force validation so corruption is repaired before publication.
|
||||
it.openHelper.writableDatabase
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
database?.close()
|
||||
appContext.deleteDatabase(name)
|
||||
build().also { it.openHelper.writableDatabase }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable client-owned state. Every future schema change requires an explicit Room migration. */
|
||||
@Database(
|
||||
entities = [
|
||||
OutboxCommandEntity::class,
|
||||
OutboxAttachmentEntity::class,
|
||||
OutboxAttachmentChunkEntity::class,
|
||||
ComposerSendAdmissionEntity::class,
|
||||
ClientStateMetadataEntity::class,
|
||||
GatewayRemovalEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
internal abstract class ClientStateDatabase : RoomDatabase() {
|
||||
abstract fun outboxDao(): ChatOutboxDao
|
||||
|
||||
abstract fun controlDao(): ClientStateControlDao
|
||||
|
||||
companion object {
|
||||
fun open(
|
||||
context: Context,
|
||||
name: String = CLIENT_STATE_DB_NAME,
|
||||
): ClientStateDatabase =
|
||||
Room
|
||||
.databaseBuilder(context.applicationContext, ClientStateDatabase::class.java, name)
|
||||
.build()
|
||||
.also {
|
||||
// Fail closed and preserve the file if durable state cannot be opened or validated.
|
||||
it.openHelper.writableDatabase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipped combined database, retained only as the one-time import owner.
|
||||
*
|
||||
* Runtime reads and writes never use this type after [AndroidClientDatabases.start] completes.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
CachedSessionEntity::class,
|
||||
CachedMessageEntity::class,
|
||||
OutboxCommandEntity::class,
|
||||
OutboxAttachmentEntity::class,
|
||||
OutboxAttachmentChunkEntity::class,
|
||||
ComposerSendAdmissionEntity::class,
|
||||
CachedGatewayOwnerEntity::class,
|
||||
],
|
||||
version = 8,
|
||||
exportSchema = false,
|
||||
)
|
||||
internal abstract class LegacyChatDatabase : RoomDatabase() {
|
||||
abstract fun dao(): ChatCacheDao
|
||||
|
||||
abstract fun outboxDao(): ChatOutboxDao
|
||||
|
||||
companion object {
|
||||
internal val MIGRATION_2_3 =
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// v2 persisted every post-dispatch exception as queued+lastError. Those rows may
|
||||
// already have run, so upgrading must park them alongside crash-interrupted sends.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET status = ?, lastError = ? " +
|
||||
"WHERE status = ? OR (status = ? AND lastError IS NOT NULL)",
|
||||
arrayOf<Any?>(
|
||||
ChatOutboxStatus.Failed.dbValue,
|
||||
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
|
||||
ChatOutboxStatus.Sending.dbValue,
|
||||
ChatOutboxStatus.Queued.dbValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_3_4 =
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER")
|
||||
// Legacy queued command-shaped rows predate connection epochs; the sentinel makes
|
||||
// them park for explicit retry instead of silently replaying on the next reconnect.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'",
|
||||
arrayOf<Any?>(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue),
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " +
|
||||
"`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " +
|
||||
"`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " +
|
||||
"`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_4_5 =
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT")
|
||||
// Agent-qualified keys carry a durable owner in the key itself. Backfill it so session
|
||||
// deletion and replay keep working after upgrade without consulting mutable defaults.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET ownerAgentId = " +
|
||||
"substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " +
|
||||
"WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1",
|
||||
)
|
||||
// Earlier rows did not persist the default agent that owned an unscoped key. Never
|
||||
// guess after upgrade: queued input stays visible for manual resend, while accepted
|
||||
// input remains delivery-ambiguous and must not be replayed under a different owner.
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET status = ?, lastError = ? " +
|
||||
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
|
||||
arrayOf<Any?>(
|
||||
ChatOutboxStatus.Failed.dbValue,
|
||||
OUTBOX_OWNER_CHANGED_ERROR,
|
||||
ChatOutboxStatus.Queued.dbValue,
|
||||
),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE outbox_commands SET status = ?, lastError = ? " +
|
||||
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
|
||||
arrayOf<Any?>(
|
||||
ChatOutboxStatus.Failed.dbValue,
|
||||
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
|
||||
ChatOutboxStatus.Accepted.dbValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_5_6 =
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Session and transcript caches are disposable, and legacy unscoped rows have no
|
||||
// provable owner. Rebuild both; the durable outbox remains intact across the upgrade.
|
||||
db.execSQL("DROP TABLE IF EXISTS `cached_sessions`")
|
||||
db.execSQL("DROP TABLE IF EXISTS `cached_messages`")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
|
||||
"`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " +
|
||||
"`timestampMs` INTEGER, `idempotencyKey` TEXT, " +
|
||||
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_6_7 =
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_7_8 =
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `composer_send_admissions` " +
|
||||
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " +
|
||||
"`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun open(
|
||||
context: Context,
|
||||
name: String,
|
||||
): LegacyChatDatabase =
|
||||
Room
|
||||
.databaseBuilder(context.applicationContext, LegacyChatDatabase::class.java, name)
|
||||
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
// v1 contains only disposable transcripts. Durable state starts in v2.
|
||||
.fallbackToDestructiveMigrationFrom(true, 1)
|
||||
.build()
|
||||
.also { it.openHelper.writableDatabase }
|
||||
}
|
||||
}
|
||||
|
||||
private class OpenedAndroidClientDatabases private constructor(
|
||||
private val context: Context,
|
||||
val gatewayCache: GatewayCacheDatabase,
|
||||
val clientState: ClientStateDatabase,
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
suspend fun open(
|
||||
context: Context,
|
||||
gatewayCacheName: String = GATEWAY_CACHE_DB_NAME,
|
||||
clientStateName: String = CLIENT_STATE_DB_NAME,
|
||||
legacyName: String = LEGACY_CHAT_DATABASE_NAME,
|
||||
registeredGatewayIds: Set<String>? = null,
|
||||
): OpenedAndroidClientDatabases {
|
||||
val appContext = context.applicationContext
|
||||
val state = ClientStateDatabase.open(appContext, clientStateName)
|
||||
var cache: GatewayCacheDatabase? = null
|
||||
return try {
|
||||
cache = GatewayCacheDatabase.open(appContext, gatewayCacheName)
|
||||
OpenedAndroidClientDatabases(appContext, cache, state).also { databases ->
|
||||
databases.importLegacyStateIfNeeded(legacyName)
|
||||
databases.resolvePendingGatewayRemovals(registeredGatewayIds)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
cache?.close()
|
||||
state.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val transcriptCache = RoomChatTranscriptCache(gatewayCache)
|
||||
|
||||
val commandOutbox = RoomChatCommandOutbox(clientState)
|
||||
|
||||
suspend fun stageGatewayRemoval(gatewayId: String) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
// A retry may race an interrupted committed purge. Never downgrade that irreversible marker.
|
||||
clientState.controlDao().insertGatewayRemovalIfAbsent(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_STAGED))
|
||||
}
|
||||
|
||||
suspend fun cancelGatewayRemoval(gatewayId: String) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
clientState.controlDao().deleteGatewayRemovalInPhase(gateway, GATEWAY_REMOVAL_STAGED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks cleanup irreversible before touching either file. A crash can leave one database ahead
|
||||
* of the other, so startup resumes this idempotent operation before publishing the stores.
|
||||
*/
|
||||
suspend fun commitGatewayRemoval(
|
||||
gatewayId: String,
|
||||
requireCacheRemoval: Boolean = false,
|
||||
) {
|
||||
val gateway = scopedGatewayId(gatewayId) ?: return
|
||||
withContext(NonCancellable) {
|
||||
// State deletion and its phase advance are atomic. A rollback leaves no irreversible marker;
|
||||
// after commit, startup may clear only disposable cache and must preserve any newer outbox rows.
|
||||
clientState.withTransaction {
|
||||
clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_COMMITTING))
|
||||
commandOutbox.clearGateway(gateway)
|
||||
clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_CACHE_PENDING))
|
||||
}
|
||||
completeCacheRemoval(gateway, propagateFailure = requireCacheRemoval)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
gatewayCache.close()
|
||||
clientState.close()
|
||||
}
|
||||
|
||||
private suspend fun importLegacyStateIfNeeded(legacyName: String) {
|
||||
val control = clientState.controlDao()
|
||||
if (control.metadataValue(LEGACY_IMPORT_KEY) == LEGACY_IMPORT_COMPLETE) {
|
||||
context.deleteDatabase(legacyName)
|
||||
return
|
||||
}
|
||||
val legacyFile = context.getDatabasePath(legacyName)
|
||||
if (!legacyFile.exists()) {
|
||||
control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE))
|
||||
return
|
||||
}
|
||||
|
||||
val legacy = LegacyChatDatabase.open(context, legacyName)
|
||||
try {
|
||||
val source = legacy.outboxDao()
|
||||
val commands = source.allCommands()
|
||||
val admissions = source.allAdmissionReceipts()
|
||||
val attachments = source.allAttachments()
|
||||
clientState.withTransaction {
|
||||
val destination = clientState.outboxDao()
|
||||
if (commands.isNotEmpty()) destination.upsertImportedCommands(commands)
|
||||
if (admissions.isNotEmpty()) destination.upsertImportedAdmissionReceipts(admissions)
|
||||
if (attachments.isNotEmpty()) destination.upsertImportedAttachments(attachments)
|
||||
}
|
||||
|
||||
var afterAttachmentId: String? = null
|
||||
var afterChunkIndex = -1
|
||||
while (true) {
|
||||
val chunks = source.attachmentChunkPage(afterAttachmentId, afterChunkIndex, LEGACY_IMPORT_CHUNK_PAGE_ROWS)
|
||||
if (chunks.isEmpty()) break
|
||||
clientState.withTransaction {
|
||||
clientState.outboxDao().upsertImportedAttachmentChunks(chunks)
|
||||
}
|
||||
chunks.last().let { cursor ->
|
||||
afterAttachmentId = cursor.attachmentId
|
||||
afterChunkIndex = cursor.chunkIndex
|
||||
}
|
||||
}
|
||||
clientState.withTransaction {
|
||||
// Earlier page commits are idempotent. This marker publishes them only after the source
|
||||
// cursor is exhausted, so a crash simply replays REPLACE inserts on the next start.
|
||||
control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE))
|
||||
}
|
||||
} finally {
|
||||
legacy.close()
|
||||
}
|
||||
// If deletion fails, the next open sees the completion marker and retries only deletion.
|
||||
context.deleteDatabase(legacyName)
|
||||
}
|
||||
|
||||
private suspend fun resolvePendingGatewayRemovals(registeredGatewayIds: Set<String>?) {
|
||||
for (removal in clientState.controlDao().gatewayRemovals()) {
|
||||
when {
|
||||
removal.phase == GATEWAY_REMOVAL_CACHE_PENDING -> completeCacheRemoval(removal.gatewayId, propagateFailure = false)
|
||||
removal.phase == GATEWAY_REMOVAL_COMMITTING -> commitGatewayRemoval(removal.gatewayId)
|
||||
registeredGatewayIds != null && removal.gatewayId !in registeredGatewayIds ->
|
||||
commitGatewayRemoval(removal.gatewayId)
|
||||
registeredGatewayIds != null -> cancelGatewayRemoval(removal.gatewayId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun completeCacheRemoval(
|
||||
gatewayId: String,
|
||||
propagateFailure: Boolean,
|
||||
) {
|
||||
try {
|
||||
transcriptCache.clearGateway(gatewayId)
|
||||
} catch (error: Exception) {
|
||||
if (propagateFailure) throw error
|
||||
// Cache is disposable. Keep cache-pending for the next open, but the durable purge has
|
||||
// committed and callers may safely retire auth without risking later outbox deletion.
|
||||
Log.w("ClientDatabases", "Deferring gateway cache cleanup", error)
|
||||
return
|
||||
}
|
||||
clientState.controlDao().deleteGatewayRemovalInPhase(gatewayId, GATEWAY_REMOVAL_CACHE_PENDING)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One installation-wide pair of multi-gateway databases initialized on a private IO scope.
|
||||
* Every facade operation awaits the one-time legacy import before reaching either Room store.
|
||||
*/
|
||||
internal class AndroidClientDatabases private constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val initialization: Deferred<OpenedAndroidClientDatabases>,
|
||||
private val openedReference: AtomicReference<OpenedAndroidClientDatabases?>,
|
||||
private val closed: AtomicBoolean,
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
fun start(
|
||||
context: Context,
|
||||
gatewayCacheName: String = GATEWAY_CACHE_DB_NAME,
|
||||
clientStateName: String = CLIENT_STATE_DB_NAME,
|
||||
legacyName: String = LEGACY_CHAT_DATABASE_NAME,
|
||||
registeredGatewayIds: Set<String>? = null,
|
||||
): AndroidClientDatabases {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val openedReference = AtomicReference<OpenedAndroidClientDatabases?>()
|
||||
val closed = AtomicBoolean(false)
|
||||
val initialization =
|
||||
scope.async {
|
||||
val opened =
|
||||
OpenedAndroidClientDatabases.open(
|
||||
context = context.applicationContext,
|
||||
gatewayCacheName = gatewayCacheName,
|
||||
clientStateName = clientStateName,
|
||||
legacyName = legacyName,
|
||||
registeredGatewayIds = registeredGatewayIds,
|
||||
)
|
||||
if (closed.get()) {
|
||||
opened.close()
|
||||
throw CancellationException("Android client databases closed during initialization")
|
||||
}
|
||||
openedReference.set(opened)
|
||||
if (closed.get() && openedReference.compareAndSet(opened, null)) {
|
||||
opened.close()
|
||||
throw CancellationException("Android client databases closed during initialization")
|
||||
}
|
||||
opened
|
||||
}
|
||||
return AndroidClientDatabases(scope, initialization, openedReference, closed)
|
||||
}
|
||||
}
|
||||
|
||||
private val transcriptCache = DeferredChatTranscriptCache(::ready)
|
||||
private val commandOutbox = DeferredChatCommandOutbox(::ready)
|
||||
|
||||
fun transcriptCache(): ChatTranscriptCache = transcriptCache
|
||||
|
||||
fun commandOutbox(): ChatCommandOutbox = commandOutbox
|
||||
|
||||
suspend fun stageGatewayRemoval(gatewayId: String) = ready().stageGatewayRemoval(gatewayId)
|
||||
|
||||
suspend fun cancelGatewayRemoval(gatewayId: String) = ready().cancelGatewayRemoval(gatewayId)
|
||||
|
||||
suspend fun commitGatewayRemoval(
|
||||
gatewayId: String,
|
||||
requireCacheRemoval: Boolean = false,
|
||||
) = ready().commitGatewayRemoval(gatewayId, requireCacheRemoval)
|
||||
|
||||
internal suspend fun gatewayCacheDatabase(): GatewayCacheDatabase = ready().gatewayCache
|
||||
|
||||
internal suspend fun clientStateDatabase(): ClientStateDatabase = ready().clientState
|
||||
|
||||
private suspend fun ready(): OpenedAndroidClientDatabases {
|
||||
check(!closed.get()) { "Android client databases are closed" }
|
||||
val opened = initialization.await()
|
||||
check(!closed.get()) { "Android client databases are closed" }
|
||||
return opened
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
scope.cancel()
|
||||
openedReference.getAndSet(null)?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private class DeferredChatTranscriptCache(
|
||||
private val ready: suspend () -> OpenedAndroidClientDatabases,
|
||||
) : ChatTranscriptCache {
|
||||
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = ready().transcriptCache.loadLastDefaultAgentId(gatewayId)
|
||||
|
||||
override suspend fun saveLastDefaultAgentId(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
) = ready().transcriptCache.saveLastDefaultAgentId(gatewayId, agentId)
|
||||
|
||||
override suspend fun loadSessions(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
): List<ChatSessionEntry> = ready().transcriptCache.loadSessions(gatewayId, agentId)
|
||||
|
||||
override suspend fun loadTranscript(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessionKey: String,
|
||||
): List<ChatMessage> = ready().transcriptCache.loadTranscript(gatewayId, agentId, sessionKey)
|
||||
|
||||
override suspend fun saveSessions(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
retainedSessionKey: String?,
|
||||
) = ready().transcriptCache.saveSessions(gatewayId, agentId, sessions, retainedSessionKey)
|
||||
|
||||
override suspend fun saveTranscript(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessionKey: String,
|
||||
messages: List<ChatMessage>,
|
||||
) = ready().transcriptCache.saveTranscript(gatewayId, agentId, sessionKey, messages)
|
||||
|
||||
override suspend fun deleteSession(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessionKey: String,
|
||||
) = ready().transcriptCache.deleteSession(gatewayId, agentId, sessionKey)
|
||||
|
||||
override suspend fun clearGateway(gatewayId: String) = ready().transcriptCache.clearGateway(gatewayId)
|
||||
}
|
||||
|
||||
private class DeferredChatCommandOutbox(
|
||||
private val ready: suspend () -> OpenedAndroidClientDatabases,
|
||||
) : ChatCommandOutbox {
|
||||
override suspend fun load(gatewayId: String): List<ChatOutboxItem> = ready().commandOutbox.load(gatewayId)
|
||||
|
||||
override suspend fun wasAdmitted(id: String): Boolean = ready().commandOutbox.wasAdmitted(id)
|
||||
|
||||
override suspend fun enqueue(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
text: String,
|
||||
thinkingLevel: String,
|
||||
nowMs: Long,
|
||||
attachments: List<OutboxAttachmentPayload>,
|
||||
gatedEpoch: Long?,
|
||||
ownerAgentId: String,
|
||||
idempotencyKey: String?,
|
||||
): ChatOutboxEnqueueResult =
|
||||
ready()
|
||||
.commandOutbox
|
||||
.enqueue(gatewayId, sessionKey, text, thinkingLevel, nowMs, attachments, gatedEpoch, ownerAgentId, idempotencyKey)
|
||||
|
||||
override suspend fun loadAttachments(id: String): List<LoadedOutboxAttachment> = ready().commandOutbox.loadAttachments(id)
|
||||
|
||||
override suspend fun updateStatus(
|
||||
id: String,
|
||||
status: ChatOutboxStatus,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
): Int = ready().commandOutbox.updateStatus(id, status, retryCount, lastError)
|
||||
|
||||
override suspend fun claimForSending(
|
||||
id: String,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
): Int = ready().commandOutbox.claimForSending(id, retryCount, lastError)
|
||||
|
||||
override suspend fun pinSessionKey(
|
||||
id: String,
|
||||
sessionKey: String,
|
||||
) = ready().commandOutbox.pinSessionKey(id, sessionKey)
|
||||
|
||||
override suspend fun requeueForRetry(
|
||||
gatewayId: String,
|
||||
id: String,
|
||||
nowMs: Long,
|
||||
gatedEpoch: Long?,
|
||||
ownerAgentId: String?,
|
||||
): Int = ready().commandOutbox.requeueForRetry(gatewayId, id, nowMs, gatedEpoch, ownerAgentId)
|
||||
|
||||
override suspend fun delete(id: String) = ready().commandOutbox.delete(id)
|
||||
|
||||
override suspend fun deleteIfQueued(id: String): Boolean = ready().commandOutbox.deleteIfQueued(id)
|
||||
|
||||
override suspend fun confirmDelivered(ids: Set<String>): Int = ready().commandOutbox.confirmDelivered(ids)
|
||||
|
||||
override suspend fun deleteForSession(
|
||||
gatewayId: String,
|
||||
sessionKey: String,
|
||||
ownerAgentId: String,
|
||||
) = ready().commandOutbox.deleteForSession(gatewayId, sessionKey, ownerAgentId)
|
||||
|
||||
override suspend fun clearGateway(gatewayId: String) = ready().commandOutbox.clearGateway(gatewayId)
|
||||
|
||||
override suspend fun failSendingAfterRestart() = ready().commandOutbox.failSendingAfterRestart()
|
||||
|
||||
override suspend fun expireStale(
|
||||
gatewayId: String,
|
||||
nowMs: Long,
|
||||
) = ready().commandOutbox.expireStale(gatewayId, nowMs)
|
||||
}
|
||||
|
||||
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -97,15 +98,23 @@ class GatewayRegistryStore(
|
||||
upsert(existing.copy(lastConnectedAtMs = atMs))
|
||||
}
|
||||
|
||||
fun remove(stableId: String): Unit =
|
||||
fun remove(stableId: String): Boolean =
|
||||
synchronized(mutationLock) {
|
||||
val normalized = stableId.trim()
|
||||
_entries.value = _entries.value.filterNot { it.stableId == normalized }
|
||||
if (_activeStableId.value == normalized) {
|
||||
_activeStableId.value = null
|
||||
onActiveChanged?.invoke(null)
|
||||
val nextEntries = _entries.value.filterNot { it.stableId == normalized }
|
||||
val previousActiveStableId = _activeStableId.value
|
||||
val nextActiveStableId = previousActiveStableId?.takeUnless { it == normalized }
|
||||
if (!persistSynchronously(nextEntries, nextActiveStableId)) return@synchronized false
|
||||
|
||||
// Publish only after the durable commit. Notification is post-commit and cannot turn a
|
||||
// successful removal into a failure that would cancel the database recovery marker.
|
||||
_entries.value = nextEntries
|
||||
_activeStableId.value = nextActiveStableId
|
||||
if (previousActiveStableId != nextActiveStableId) {
|
||||
runCatching { onActiveChanged?.invoke(nextActiveStableId) }
|
||||
.onFailure { Log.e("GatewayRegistry", "Active-gateway observer failed after durable removal", it) }
|
||||
}
|
||||
persist()
|
||||
true
|
||||
}
|
||||
|
||||
fun activeEntry(): GatewayRegistryEntry? =
|
||||
@@ -117,14 +126,25 @@ class GatewayRegistryStore(
|
||||
internal fun storedActiveStableId(): String? = decode(prefs.getString(STORAGE_KEY)).activeStableId
|
||||
|
||||
private fun persist() {
|
||||
val registry =
|
||||
PersistedGatewayRegistry(
|
||||
activeStableId = _activeStableId.value,
|
||||
entries = _entries.value.sortedForStorage(),
|
||||
)
|
||||
prefs.putString(STORAGE_KEY, json.encodeToString(registry))
|
||||
prefs.putString(STORAGE_KEY, encodedRegistry())
|
||||
}
|
||||
|
||||
private fun persistSynchronously(
|
||||
entries: List<GatewayRegistryEntry>,
|
||||
activeStableId: String?,
|
||||
): Boolean = prefs.putStringSynchronously(STORAGE_KEY, encodedRegistry(entries, activeStableId))
|
||||
|
||||
private fun encodedRegistry(
|
||||
entries: List<GatewayRegistryEntry> = _entries.value,
|
||||
activeStableId: String? = _activeStableId.value,
|
||||
): String =
|
||||
json.encodeToString(
|
||||
PersistedGatewayRegistry(
|
||||
activeStableId = activeStableId,
|
||||
entries = entries.sortedForStorage(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun decode(raw: String?): PersistedGatewayRegistry =
|
||||
raw
|
||||
?.let { runCatching { json.decodeFromString<PersistedGatewayRegistry>(it) }.getOrNull() }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.ChatTranscriptCache
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayConnectOptions
|
||||
@@ -849,6 +852,25 @@ class GatewayBootstrapAuthTest {
|
||||
assertEquals("other-node-token", authStore.loadToken(other, deviceId, "node"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetGatewaySetupAuthClearsInjectedTranscriptStore() =
|
||||
runBlocking {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val transcriptCache = RecordingTranscriptCache()
|
||||
val runtime = NodeRuntime(app, prefs, transcriptCache)
|
||||
val target = GatewayEndpoint.manual("target.example", 18789).stableId
|
||||
|
||||
assertTrue(runtime.resetGatewaySetupAuth(target))
|
||||
|
||||
assertEquals(listOf(target), transcriptCache.clearedGatewayIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchToUndiscoveredGatewayKeepsCurrentConnectionAndActiveGateway() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
@@ -1507,4 +1529,50 @@ class GatewayBootstrapAuthTest {
|
||||
}
|
||||
error("Field $name not found on ${target.javaClass.name}")
|
||||
}
|
||||
|
||||
private class RecordingTranscriptCache : ChatTranscriptCache {
|
||||
val clearedGatewayIds = mutableListOf<String>()
|
||||
|
||||
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = null
|
||||
|
||||
override suspend fun saveLastDefaultAgentId(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
) = Unit
|
||||
|
||||
override suspend fun loadSessions(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
): List<ChatSessionEntry> = emptyList()
|
||||
|
||||
override suspend fun loadTranscript(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessionKey: String,
|
||||
): List<ChatMessage> = emptyList()
|
||||
|
||||
override suspend fun saveSessions(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
retainedSessionKey: String?,
|
||||
) = Unit
|
||||
|
||||
override suspend fun saveTranscript(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessionKey: String,
|
||||
messages: List<ChatMessage>,
|
||||
) = Unit
|
||||
|
||||
override suspend fun deleteSession(
|
||||
gatewayId: String,
|
||||
agentId: String,
|
||||
sessionKey: String,
|
||||
) = Unit
|
||||
|
||||
override suspend fun clearGateway(gatewayId: String) {
|
||||
clearedGatewayIds += gatewayId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ChatCacheDatabaseMigrationTest {
|
||||
@Test
|
||||
fun v2AmbiguousRowsMigrateToManualOnlyAndPreservePristineQueue() =
|
||||
runTest {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val databaseName = "chat-cache-migration-${UUID.randomUUID()}.db"
|
||||
val databaseFile = context.getDatabasePath(databaseName)
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
createV2Fixture(databaseFile.path)
|
||||
|
||||
val database = ChatCacheDatabase.open(context, databaseName)
|
||||
try {
|
||||
// Opening through Room executes the production migration chain and validates the
|
||||
// complete v8 schema, including columns, nullability, primary keys, and indices.
|
||||
assertEquals(8, database.openHelper.writableDatabase.version)
|
||||
|
||||
val outbox = RoomChatCommandOutbox(database)
|
||||
val rows = outbox.load("gateway-test").associateBy { it.id }
|
||||
val pristine = rows.getValue("pristine")
|
||||
assertEquals(ChatOutboxStatus.Failed, pristine.status)
|
||||
assertEquals(OUTBOX_OWNER_CHANGED_ERROR, pristine.lastError)
|
||||
assertNull(pristine.ownerAgentId)
|
||||
assertNull(pristine.gatedEpoch)
|
||||
assertTrue(pristine.attachments.isEmpty())
|
||||
|
||||
for (id in listOf("legacy-queued-error", "interrupted-send")) {
|
||||
val migrated = rows.getValue(id)
|
||||
assertEquals(ChatOutboxStatus.Failed, migrated.status)
|
||||
assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, migrated.lastError)
|
||||
}
|
||||
val alreadyFailed = rows.getValue("already-failed")
|
||||
assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status)
|
||||
assertEquals("original failure", alreadyFailed.lastError)
|
||||
val accepted = rows.getValue("accepted")
|
||||
assertEquals(ChatOutboxStatus.Failed, accepted.status)
|
||||
assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, accepted.lastError)
|
||||
val explicitOwner = rows.getValue("explicit-owner")
|
||||
assertEquals(ChatOutboxStatus.Queued, explicitOwner.status)
|
||||
assertEquals("ops", explicitOwner.ownerAgentId)
|
||||
outbox.deleteForSession("gateway-test", "agent:ops:side", "ops")
|
||||
assertTrue(outbox.load("gateway-test").none { it.id == explicitOwner.id })
|
||||
// Legacy queued command-shaped rows predate connection epochs; the sentinel keeps
|
||||
// them from silently replaying on the next reconnect (they park for explicit retry).
|
||||
val legacyCommand = rows.getValue("legacy-command")
|
||||
assertEquals(ChatOutboxStatus.Failed, legacyCommand.status)
|
||||
assertEquals(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch)
|
||||
assertEquals(OUTBOX_OWNER_CHANGED_ERROR, legacyCommand.lastError)
|
||||
// Legacy session and transcript rows have no trustworthy agent owner. Both disposable
|
||||
// caches are rebuilt instead of exposing another agent's metadata or history.
|
||||
assertTrue(database.dao().sessions("gateway-test", "main").isEmpty())
|
||||
assertTrue(database.dao().messages("gateway-test", "main", "main").isEmpty())
|
||||
} finally {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upgradedStoreSupportsAttachmentsAndSurvivesReopen() =
|
||||
runTest {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val databaseName = "chat-cache-migration-${UUID.randomUUID()}.db"
|
||||
val databaseFile = context.getDatabasePath(databaseName)
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
createV2Fixture(databaseFile.path)
|
||||
|
||||
// Spans multiple chunks to prove chunked reassembly is byte-exact after a real upgrade.
|
||||
val bytes = ByteArray(OUTBOX_ATTACHMENT_CHUNK_BYTES + 77) { (it % 127).toByte() }
|
||||
val queuedId: String
|
||||
val first = ChatCacheDatabase.open(context, databaseName)
|
||||
try {
|
||||
val queued =
|
||||
RoomChatCommandOutbox(first).enqueue(
|
||||
gatewayId = "gateway-test",
|
||||
sessionKey = "main",
|
||||
text = "post-upgrade media",
|
||||
thinkingLevel = "off",
|
||||
nowMs = System.currentTimeMillis(),
|
||||
ownerAgentId = "main",
|
||||
attachments =
|
||||
listOf(
|
||||
OutboxAttachmentPayload(type = "image", mimeType = "image/jpeg", fileName = "a.jpg", durationMs = null, bytes = bytes),
|
||||
),
|
||||
) as ChatOutboxEnqueueResult.Queued
|
||||
queuedId = queued.item.id
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
// Process-restart analog: a fresh open must recover the exact bytes.
|
||||
val reopened = ChatCacheDatabase.open(context, databaseName)
|
||||
try {
|
||||
val loaded = RoomChatCommandOutbox(reopened).loadAttachments(queuedId)
|
||||
assertEquals(1, loaded.size)
|
||||
assertTrue(bytes.contentEquals(loaded.single().bytes))
|
||||
} finally {
|
||||
reopened.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createV2Fixture(path: String) {
|
||||
SQLiteDatabase.openOrCreateDatabase(path, null).use { database ->
|
||||
val now = System.currentTimeMillis()
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, " +
|
||||
"`updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `sessionKey`))",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, " +
|
||||
"`role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, " +
|
||||
"`idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `sessionKey`, `rowOrder`))",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_commands` " +
|
||||
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
|
||||
"`text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, " +
|
||||
"`status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
|
||||
)
|
||||
database.execSQL(
|
||||
"INSERT INTO cached_sessions " +
|
||||
"(gatewayId, sessionKey, displayName, updatedAtMs, rowOrder) VALUES (?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>("gateway-test", "main", "Cached session", 10L, 0),
|
||||
)
|
||||
database.execSQL(
|
||||
"INSERT INTO cached_messages " +
|
||||
"(gatewayId, sessionKey, rowOrder, role, textPartsJson, timestampMs, idempotencyKey) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>("gateway-test", "main", 0, "assistant", "[\"legacy transcript\"]", 10L, null),
|
||||
)
|
||||
insertOutbox(database, id = "pristine", status = "queued", retryCount = 0, lastError = null, createdAtMs = now)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "legacy-queued-error",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = "socket closed after send",
|
||||
createdAtMs = now + 1,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "interrupted-send",
|
||||
status = "sending",
|
||||
retryCount = 1,
|
||||
lastError = null,
|
||||
createdAtMs = now + 2,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "already-failed",
|
||||
status = "failed",
|
||||
retryCount = 3,
|
||||
lastError = "original failure",
|
||||
createdAtMs = now + 3,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "legacy-command",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 4,
|
||||
text = "/clear",
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "accepted",
|
||||
status = "accepted",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 5,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "explicit-owner",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 6,
|
||||
sessionKey = "agent:ops:side",
|
||||
)
|
||||
database.version = 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertOutbox(
|
||||
database: SQLiteDatabase,
|
||||
id: String,
|
||||
status: String,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
createdAtMs: Long,
|
||||
text: String = id,
|
||||
sessionKey: String = "main",
|
||||
) {
|
||||
database.execSQL(
|
||||
"INSERT INTO outbox_commands " +
|
||||
"(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(id, "gateway-test", sessionKey, text, "off", createdAtMs, status, retryCount, lastError),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ClientDatabasesTest {
|
||||
@Test
|
||||
fun v2DurableRowsImportIntoClientStateWhileLegacyCacheIsDiscarded() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
createV2Fixture(context.getDatabasePath(names.legacy).path)
|
||||
|
||||
val databases = open(names, registeredGatewayIds = setOf("gateway-test"))
|
||||
try {
|
||||
assertEquals(
|
||||
1,
|
||||
databases
|
||||
.gatewayCacheDatabase()
|
||||
.openHelper.writableDatabase.version,
|
||||
)
|
||||
assertEquals(
|
||||
1,
|
||||
databases
|
||||
.clientStateDatabase()
|
||||
.openHelper.writableDatabase.version,
|
||||
)
|
||||
|
||||
val rows = databases.commandOutbox().load("gateway-test").associateBy { it.id }
|
||||
val pristine = rows.getValue("pristine")
|
||||
assertEquals(ChatOutboxStatus.Failed, pristine.status)
|
||||
assertEquals(OUTBOX_OWNER_CHANGED_ERROR, pristine.lastError)
|
||||
assertNull(pristine.ownerAgentId)
|
||||
assertNull(pristine.gatedEpoch)
|
||||
assertTrue(pristine.attachments.isEmpty())
|
||||
|
||||
for (id in listOf("legacy-queued-error", "interrupted-send")) {
|
||||
val migrated = rows.getValue(id)
|
||||
assertEquals(ChatOutboxStatus.Failed, migrated.status)
|
||||
assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, migrated.lastError)
|
||||
}
|
||||
val alreadyFailed = rows.getValue("already-failed")
|
||||
assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status)
|
||||
assertEquals("original failure", alreadyFailed.lastError)
|
||||
val accepted = rows.getValue("accepted")
|
||||
assertEquals(ChatOutboxStatus.Failed, accepted.status)
|
||||
assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, accepted.lastError)
|
||||
val explicitOwner = rows.getValue("explicit-owner")
|
||||
assertEquals(ChatOutboxStatus.Queued, explicitOwner.status)
|
||||
assertEquals("ops", explicitOwner.ownerAgentId)
|
||||
databases.commandOutbox().deleteForSession("gateway-test", "agent:ops:side", "ops")
|
||||
assertTrue(databases.commandOutbox().load("gateway-test").none { it.id == explicitOwner.id })
|
||||
|
||||
val legacyCommand = rows.getValue("legacy-command")
|
||||
assertEquals(ChatOutboxStatus.Failed, legacyCommand.status)
|
||||
assertEquals(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch)
|
||||
assertEquals(OUTBOX_OWNER_CHANGED_ERROR, legacyCommand.lastError)
|
||||
|
||||
// Legacy gateway snapshots are disposable and never cross into the new cache file.
|
||||
assertTrue(databases.transcriptCache().loadSessions("gateway-test", "main").isEmpty())
|
||||
assertTrue(databases.transcriptCache().loadTranscript("gateway-test", "main", "main").isEmpty())
|
||||
assertFalse(context.getDatabasePath(names.legacy).exists())
|
||||
assertTrue(context.getDatabasePath(names.cache).exists())
|
||||
assertTrue(context.getDatabasePath(names.state).exists())
|
||||
} finally {
|
||||
databases.close()
|
||||
delete(names)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v8AttachmentBytesAndAdmissionReceiptsImportOnceAndSurviveReopen() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
createV2Fixture(context.getDatabasePath(names.legacy).path)
|
||||
val bytes = ByteArray((OUTBOX_ATTACHMENT_CHUNK_BYTES * 9) + 77) { (it % 127).toByte() }
|
||||
addV8AttachmentFixture(names.legacy, bytes)
|
||||
|
||||
val first = open(names, registeredGatewayIds = setOf("gateway-test"))
|
||||
try {
|
||||
val loaded = first.commandOutbox().loadAttachments("media-command")
|
||||
assertEquals(1, loaded.size)
|
||||
assertTrue(bytes.contentEquals(loaded.single().bytes))
|
||||
assertTrue(first.commandOutbox().wasAdmitted("media-command"))
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
// A fresh open reads only client-state.db. The completion marker prevents a stale legacy
|
||||
// file from being imported twice if deletion was interrupted.
|
||||
val reopened = open(names, registeredGatewayIds = setOf("gateway-test"))
|
||||
try {
|
||||
val loaded = reopened.commandOutbox().loadAttachments("media-command")
|
||||
assertEquals(1, loaded.size)
|
||||
assertTrue(bytes.contentEquals(loaded.single().bytes))
|
||||
assertTrue(reopened.commandOutbox().wasAdmitted("media-command"))
|
||||
} finally {
|
||||
reopened.close()
|
||||
delete(names)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheFormatMismatchRebuildsWithoutTouchingClientState() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val first = open(names, registeredGatewayIds = setOf("gateway-a"))
|
||||
try {
|
||||
first.transcriptCache().saveTranscript(
|
||||
gatewayId = "gateway-a",
|
||||
agentId = "main",
|
||||
sessionKey = "main",
|
||||
messages = listOf(cachedMessage("cache me")),
|
||||
)
|
||||
assertTrue(
|
||||
first.commandOutbox().enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "preserve me",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 1,
|
||||
ownerAgentId = "main",
|
||||
) is ChatOutboxEnqueueResult.Queued,
|
||||
)
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(names.cache).path, null, SQLiteDatabase.OPEN_READWRITE).use {
|
||||
it.version = 99
|
||||
}
|
||||
|
||||
val reopened = open(names, registeredGatewayIds = setOf("gateway-a"))
|
||||
try {
|
||||
assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty())
|
||||
assertEquals(listOf("preserve me"), reopened.commandOutbox().load("gateway-a").map { it.text })
|
||||
} finally {
|
||||
reopened.close()
|
||||
delete(names)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientStateFormatMismatchFailsClosedWithoutDeletingDurableFile() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val first = open(names, registeredGatewayIds = setOf("gateway-a"))
|
||||
try {
|
||||
seedGateway(first, "gateway-a", "preserve")
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
val statePath = context.getDatabasePath(names.state).path
|
||||
SQLiteDatabase.openDatabase(statePath, null, SQLiteDatabase.OPEN_READWRITE).use {
|
||||
it.version = 99
|
||||
}
|
||||
|
||||
val failedOpen = open(names, registeredGatewayIds = setOf("gateway-a"))
|
||||
val failure = runCatching { failedOpen.clientStateDatabase() }
|
||||
failedOpen.close()
|
||||
assertTrue(failure.isFailure)
|
||||
assertTrue(context.getDatabasePath(names.state).exists())
|
||||
SQLiteDatabase.openDatabase(statePath, null, SQLiteDatabase.OPEN_READONLY).use {
|
||||
assertEquals(99, it.version)
|
||||
}
|
||||
delete(names)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentGatewayCommitsStagedRemovalAcrossBothDatabasesAndKeepsOtherGateway() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val first = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b"))
|
||||
try {
|
||||
seedGateway(first, "gateway-a", "remove")
|
||||
seedGateway(first, "gateway-b", "keep")
|
||||
first.stageGatewayRemoval("gateway-a")
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
val reopened = open(names, registeredGatewayIds = setOf("gateway-b"))
|
||||
try {
|
||||
assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty())
|
||||
assertTrue(reopened.commandOutbox().load("gateway-a").isEmpty())
|
||||
assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-b", "main", "main").map { it.content.single().text })
|
||||
assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-b").map { it.text })
|
||||
} finally {
|
||||
reopened.close()
|
||||
delete(names)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachePendingRemovalNeverDeletesNewDurableRowsOnResume() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val first = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b"))
|
||||
try {
|
||||
seedGateway(first, "gateway-a", "remove")
|
||||
seedGateway(first, "gateway-b", "keep")
|
||||
// Force only the disposable half to fail after the durable state transaction commits.
|
||||
first.gatewayCacheDatabase().close()
|
||||
first.commitGatewayRemoval("gateway-a")
|
||||
assertTrue(first.commandOutbox().load("gateway-a").isEmpty())
|
||||
assertTrue(
|
||||
first.commandOutbox().enqueue(
|
||||
gatewayId = "gateway-a",
|
||||
sessionKey = "main",
|
||||
text = "new after purge",
|
||||
thinkingLevel = "off",
|
||||
nowMs = 2,
|
||||
ownerAgentId = "main",
|
||||
) is ChatOutboxEnqueueResult.Queued,
|
||||
)
|
||||
// A retry may stage again before restart; it must not downgrade cache-pending into a
|
||||
// cancelable marker that could strand the old derived rows.
|
||||
first.stageGatewayRemoval("gateway-a")
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
val reopened = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b"))
|
||||
try {
|
||||
assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty())
|
||||
assertEquals(listOf("new after purge"), reopened.commandOutbox().load("gateway-a").map { it.text })
|
||||
assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-b", "main", "main").map { it.content.single().text })
|
||||
assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-b").map { it.text })
|
||||
assertTrue(
|
||||
reopened
|
||||
.clientStateDatabase()
|
||||
.controlDao()
|
||||
.gatewayRemovals()
|
||||
.isEmpty(),
|
||||
)
|
||||
} finally {
|
||||
reopened.close()
|
||||
delete(names)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stillRegisteredGatewayCancelsCancelableStagedRemoval() =
|
||||
runTest {
|
||||
val names = databaseNames()
|
||||
val first = open(names, registeredGatewayIds = setOf("gateway-a"))
|
||||
try {
|
||||
seedGateway(first, "gateway-a", "keep")
|
||||
first.stageGatewayRemoval("gateway-a")
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
|
||||
val reopened = open(names, registeredGatewayIds = setOf("gateway-a"))
|
||||
try {
|
||||
assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").map { it.content.single().text })
|
||||
assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-a").map { it.text })
|
||||
} finally {
|
||||
reopened.close()
|
||||
delete(names)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun seedGateway(
|
||||
databases: AndroidClientDatabases,
|
||||
gatewayId: String,
|
||||
text: String,
|
||||
) {
|
||||
databases.transcriptCache().saveTranscript(
|
||||
gatewayId = gatewayId,
|
||||
agentId = "main",
|
||||
sessionKey = "main",
|
||||
messages = listOf(cachedMessage(text)),
|
||||
)
|
||||
assertTrue(
|
||||
databases.commandOutbox().enqueue(
|
||||
gatewayId = gatewayId,
|
||||
sessionKey = "main",
|
||||
text = text,
|
||||
thinkingLevel = "off",
|
||||
nowMs = 1,
|
||||
ownerAgentId = "main",
|
||||
) is ChatOutboxEnqueueResult.Queued,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cachedMessage(text: String): ChatMessage =
|
||||
ChatMessage(
|
||||
id = "id-$text",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = text)),
|
||||
timestampMs = 1,
|
||||
)
|
||||
|
||||
private fun addV8AttachmentFixture(
|
||||
legacyName: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val legacy = LegacyChatDatabase.open(context, legacyName)
|
||||
try {
|
||||
val database = legacy.openHelper.writableDatabase
|
||||
database.execSQL(
|
||||
"INSERT INTO outbox_commands " +
|
||||
"(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError, gatedEpoch, ownerAgentId) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>("media-command", "gateway-test", "main", "media", "off", 100L, "queued", 0, null, null, "main"),
|
||||
)
|
||||
database.execSQL(
|
||||
"INSERT INTO composer_send_admissions (id, gatewayId, ownerAgentId, sessionKey) VALUES (?, ?, ?, ?)",
|
||||
arrayOf<Any?>("media-command", "gateway-test", "main", "main"),
|
||||
)
|
||||
database.execSQL(
|
||||
"INSERT INTO outbox_attachments (id, commandId, position, type, mimeType, fileName, durationMs, byteLength) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>("media-attachment", "media-command", 0, "image", "image/jpeg", "a.jpg", null, bytes.size.toLong()),
|
||||
)
|
||||
var offset = 0
|
||||
var index = 0
|
||||
while (offset < bytes.size) {
|
||||
val end = minOf(offset + OUTBOX_ATTACHMENT_CHUNK_BYTES, bytes.size)
|
||||
database.execSQL(
|
||||
"INSERT INTO outbox_attachment_chunks (attachmentId, chunkIndex, bytes) VALUES (?, ?, ?)",
|
||||
arrayOf<Any?>("media-attachment", index, bytes.copyOfRange(offset, end)),
|
||||
)
|
||||
offset = end
|
||||
index += 1
|
||||
}
|
||||
} finally {
|
||||
legacy.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun open(
|
||||
names: DatabaseNames,
|
||||
registeredGatewayIds: Set<String>,
|
||||
): AndroidClientDatabases =
|
||||
AndroidClientDatabases.start(
|
||||
RuntimeEnvironment.getApplication(),
|
||||
gatewayCacheName = names.cache,
|
||||
clientStateName = names.state,
|
||||
legacyName = names.legacy,
|
||||
registeredGatewayIds = registeredGatewayIds,
|
||||
)
|
||||
|
||||
private fun databaseNames(): DatabaseNames {
|
||||
val id = UUID.randomUUID().toString()
|
||||
return DatabaseNames(
|
||||
cache = "gateway-cache-$id.db",
|
||||
state = "client-state-$id.db",
|
||||
legacy = "chat-transcript-cache-$id.db",
|
||||
)
|
||||
}
|
||||
|
||||
private fun delete(names: DatabaseNames) {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
context.deleteDatabase(names.cache)
|
||||
context.deleteDatabase(names.state)
|
||||
context.deleteDatabase(names.legacy)
|
||||
}
|
||||
|
||||
private data class DatabaseNames(
|
||||
val cache: String,
|
||||
val state: String,
|
||||
val legacy: String,
|
||||
)
|
||||
|
||||
private fun createV2Fixture(path: String) {
|
||||
SQLiteDatabase.openOrCreateDatabase(path, null).use { database ->
|
||||
val now = System.currentTimeMillis()
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, " +
|
||||
"`updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `sessionKey`))",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
|
||||
"(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, " +
|
||||
"`role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, " +
|
||||
"`idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `sessionKey`, `rowOrder`))",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `outbox_commands` " +
|
||||
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
|
||||
"`text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, " +
|
||||
"`status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
|
||||
)
|
||||
database.execSQL(
|
||||
"INSERT INTO cached_sessions " +
|
||||
"(gatewayId, sessionKey, displayName, updatedAtMs, rowOrder) VALUES (?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>("gateway-test", "main", "Cached session", 10L, 0),
|
||||
)
|
||||
database.execSQL(
|
||||
"INSERT INTO cached_messages " +
|
||||
"(gatewayId, sessionKey, rowOrder, role, textPartsJson, timestampMs, idempotencyKey) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>("gateway-test", "main", 0, "assistant", "[\"legacy transcript\"]", 10L, null),
|
||||
)
|
||||
insertOutbox(database, id = "pristine", status = "queued", retryCount = 0, lastError = null, createdAtMs = now)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "legacy-queued-error",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = "socket closed after send",
|
||||
createdAtMs = now + 1,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "interrupted-send",
|
||||
status = "sending",
|
||||
retryCount = 1,
|
||||
lastError = null,
|
||||
createdAtMs = now + 2,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "already-failed",
|
||||
status = "failed",
|
||||
retryCount = 3,
|
||||
lastError = "original failure",
|
||||
createdAtMs = now + 3,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "legacy-command",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 4,
|
||||
text = "/clear",
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "accepted",
|
||||
status = "accepted",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 5,
|
||||
)
|
||||
insertOutbox(
|
||||
database,
|
||||
id = "explicit-owner",
|
||||
status = "queued",
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
createdAtMs = now + 6,
|
||||
sessionKey = "agent:ops:side",
|
||||
)
|
||||
database.version = 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertOutbox(
|
||||
database: SQLiteDatabase,
|
||||
id: String,
|
||||
status: String,
|
||||
retryCount: Int,
|
||||
lastError: String?,
|
||||
createdAtMs: Long,
|
||||
text: String = id,
|
||||
sessionKey: String = "main",
|
||||
) {
|
||||
database.execSQL(
|
||||
"INSERT INTO outbox_commands " +
|
||||
"(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(id, "gateway-test", sessionKey, text, "off", createdAtMs, status, retryCount, lastError),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RoomChatCommandOutboxTest {
|
||||
private val database: ChatCacheDatabase =
|
||||
private val database: ClientStateDatabase =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ChatCacheDatabase::class.java)
|
||||
.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ClientStateDatabase::class.java)
|
||||
.build()
|
||||
|
||||
private val store = RoomChatCommandOutbox(database = database)
|
||||
|
||||
@@ -12,9 +12,9 @@ import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RoomChatTranscriptCacheTest {
|
||||
private val database: ChatCacheDatabase =
|
||||
private val database: GatewayCacheDatabase =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ChatCacheDatabase::class.java)
|
||||
.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), GatewayCacheDatabase::class.java)
|
||||
.build()
|
||||
|
||||
@After
|
||||
|
||||
@@ -2,8 +2,11 @@ package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -29,9 +32,13 @@ class GatewayRegistryStoreTest {
|
||||
assertEquals(alpha.stableId, restored.activeStableId.value)
|
||||
assertEquals(42L, restored.activeEntry()?.lastConnectedAtMs)
|
||||
|
||||
restored.remove(alpha.stableId)
|
||||
assertTrue(restored.remove(alpha.stableId))
|
||||
assertNull(restored.activeStableId.value)
|
||||
assertEquals(listOf(beta.stableId), restored.entries.value.map { it.stableId })
|
||||
|
||||
val afterRemoval = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
assertNull(afterRemoval.activeStableId.value)
|
||||
assertEquals(listOf(beta.stableId), afterRemoval.entries.value.map { it.stableId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,6 +64,57 @@ class GatewayRegistryStoreTest {
|
||||
assertEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedRemovalCommitDoesNotPublishCandidateState() {
|
||||
val (_, securePrefs) = freshPrefs()
|
||||
val failingCommitPrefs =
|
||||
object : SharedPreferences by securePrefs {
|
||||
override fun edit(): SharedPreferences.Editor {
|
||||
val editor = securePrefs.edit()
|
||||
return object : SharedPreferences.Editor by editor {
|
||||
override fun putString(
|
||||
key: String?,
|
||||
value: String?,
|
||||
): SharedPreferences.Editor {
|
||||
editor.putString(key, value)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun commit(): Boolean = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val store = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), failingCommitPrefs))
|
||||
val alpha = manualEntry("alpha", "alpha.example")
|
||||
store.upsert(alpha)
|
||||
store.setActive(alpha.stableId)
|
||||
|
||||
assertFalse(store.remove(alpha.stableId))
|
||||
assertEquals(listOf(alpha.stableId), store.entries.value.map { it.stableId })
|
||||
assertEquals(alpha.stableId, store.activeStableId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun postCommitObserverFailureDoesNotUndoDurableRemoval() {
|
||||
val (prefs, securePrefs) = freshPrefs()
|
||||
var failObserver = false
|
||||
val store =
|
||||
GatewayRegistryStore(prefs) {
|
||||
if (failObserver) error("simulated observer failure")
|
||||
}
|
||||
val alpha = manualEntry("alpha", "alpha.example")
|
||||
store.upsert(alpha)
|
||||
store.setActive(alpha.stableId)
|
||||
failObserver = true
|
||||
|
||||
assertTrue(store.remove(alpha.stableId))
|
||||
assertTrue(store.entries.value.isEmpty())
|
||||
assertNull(store.activeStableId.value)
|
||||
val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
assertTrue(restored.entries.value.isEmpty())
|
||||
assertNull(restored.activeStableId.value)
|
||||
}
|
||||
|
||||
private fun freshPrefs(): Pair<SecurePrefs, android.content.SharedPreferences> {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
context
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/session-transcript-reconcile.js";
|
||||
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { readSessionMessagesAroundIdWithStatsAsync } from "./session-transcript-anchor-reader.js";
|
||||
import {
|
||||
@@ -31,6 +33,8 @@ describe("session transcript reader facade", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
envSnapshot.restore();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user