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:
Peter Steinberger
2026-07-20 02:28:02 -07:00
committed by GitHub
parent 473f2aca33
commit 7defd4a7b4
18 changed files with 2030 additions and 597 deletions
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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
}
}
}
@@ -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 });
});