Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift
Peter Steinberger 5d8293c1fe feat(ios): read-only offline cache for chat sessions and transcripts (#100219)
* feat(ios): read-only offline cache for chat sessions and transcripts

Cache-first cold open for the iOS chat tab: the last known transcript and
session list render immediately from a local SQLite cache, then live gateway
history replaces them wholesale through the existing reconciliation path.
When the gateway is unreachable, recent sessions and transcripts stay
browsable read-only; sending remains gated by the existing connection state.

- New OpenClawChatTranscriptCache protocol seam plus SQLite-backed store in
  OpenClawChatUI (raw SQLite3, no new dependencies), scoped per gateway
  stableID so transcripts never leak across paired gateways.
- Bounds: 50 sessions, 50 transcripts, 200 messages per session; text rows
  only (attachment/binary payloads and tool arguments are stripped).
- Disposable cache: schema versioned via user_version; any open/schema/decode
  mismatch drops and rebuilds silently. File protection
  completeUntilFirstUserAuthentication on iOS.
- View model pre-paints from cache only until a live response applies
  (hasAppliedLiveHistory/hasAppliedLiveSessions guards); write-through is
  chained and off the render path.
- iOS wiring keys the chat view model identity on transport mode plus cache
  gateway ID so switching paired gateways rebuilds the view model.

Part of #100194

* fix(ios): harden offline transcript cache

* fix(ios): expose offline cached sessions

* fix(ios): isolate transcript caches by gateway

* chore(i18n): sync native inventory

* fix(ios): allow cache extension to replace messages
2026-07-06 01:02:15 +01:00

381 lines
15 KiB
Swift

import Foundation
import OSLog
import SQLite3
#if os(iOS)
import UIKit
#endif
private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatTranscriptCache")
/// Read-only offline cache seam for chat sessions and transcripts.
///
/// The cache only pre-paints cold opens and covers offline browsing; connected
/// reads always come from the gateway and replace cached content wholesale.
/// Implementations must scope all rows to a single gateway identity so
/// transcripts never leak across paired gateways.
public protocol OpenClawChatTranscriptCache: Sendable {
func loadSessions() async -> [OpenClawChatSessionEntry]
func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage]
func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async
func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async
}
/// SQLite-backed transcript cache for one gateway identity. Owners should use
/// one database file per gateway so reset can physically remove that gateway's
/// cached transcript bytes without disturbing other paired gateways.
///
/// The cache is disposable: any open, schema, or decode mismatch drops the
/// affected state and rebuilds silently. There are no migrations.
public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache {
/// Bounds keep the cache small: enough for a recently-used session picker
/// and a full first screen of transcript, not a durable archive.
public static let maxCachedSessions = 50
public static let maxCachedTranscripts = 50
public static let maxCachedMessagesPerSession = 200
static let schemaVersion: Int32 = 1
/// Owns the raw sqlite handle so it closes on release without needing an
/// isolated actor deinit (OpaquePointer is not Sendable).
private final class Connection: @unchecked Sendable {
let raw: OpaquePointer
init(raw: OpaquePointer) {
self.raw = raw
}
deinit {
sqlite3_close_v2(self.raw)
}
}
private let databaseURL: URL
private let gatewayID: String
private var db: Connection?
private var isRetired = false
/// After a failed drop-and-rebuild the cache becomes a no-op instead of
/// erroring the chat surface; a fresh launch retries from scratch.
private var isBroken = false
public init(databaseURL: URL, gatewayID: String) {
self.databaseURL = databaseURL
self.gatewayID = gatewayID
}
/// Startup-only cleanup, before any cache actor can own an open handle.
public static func removeDatabaseFiles(at databaseURL: URL) {
let fm = FileManager.default
try? fm.removeItem(at: databaseURL)
for suffix in ["-wal", "-shm", "-journal"] {
try? fm.removeItem(at: URL(fileURLWithPath: databaseURL.path + suffix))
}
}
// MARK: - OpenClawChatTranscriptCache
public func loadSessions() async -> [OpenClawChatSessionEntry] {
guard !self.isRetired else { return [] }
guard let db = await self.handle() else { return [] }
guard let payload = self.selectPayload(
db,
sql: "SELECT payload FROM cached_sessions WHERE gateway_id = ?1",
bindings: [self.gatewayID])
else {
return []
}
guard let decoded = try? JSONDecoder().decode(
[OpenClawChatSessionEntry].self,
from: Data(payload.utf8))
else {
// Decode mismatch means a stale/foreign shape: drop the row silently.
self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID])
return []
}
return decoded
}
public func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] {
guard !self.isRetired else { return [] }
guard let db = await self.handle() else { return [] }
guard let payload = self.selectPayload(
db,
sql: "SELECT payload FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
bindings: [self.gatewayID, sessionKey])
else {
return []
}
guard let decoded = try? JSONDecoder().decode(
[OpenClawChatMessage].self,
from: Data(payload.utf8))
else {
self.execute(
db,
sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
bindings: [self.gatewayID, sessionKey])
return []
}
return decoded
}
public func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async {
guard !self.isRetired else { return }
guard let db = await self.handle() else { return }
let bounded = Self.boundedSessions(sessions)
guard !bounded.isEmpty else {
self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID])
return
}
guard let payload = Self.encodeJSON(bounded) else { return }
self.execute(
db,
sql: """
INSERT OR REPLACE INTO cached_sessions(gateway_id, payload, updated_at)
VALUES (?1, ?2, ?3)
""",
bindings: [self.gatewayID, payload, Date().timeIntervalSince1970])
}
public func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async {
guard !self.isRetired else { return }
guard let db = await self.handle() else { return }
let bounded = Self.cacheableMessages(messages)
guard !bounded.isEmpty else {
// An emptied live transcript must also empty the cache, or the next
// cold open would ghost-paint messages the gateway no longer has.
self.execute(
db,
sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
bindings: [self.gatewayID, sessionKey])
return
}
guard let payload = Self.encodeJSON(bounded) else { return }
self.execute(
db,
sql: """
INSERT OR REPLACE INTO cached_transcripts(gateway_id, session_key, payload, updated_at)
VALUES (?1, ?2, ?3, ?4)
""",
bindings: [self.gatewayID, sessionKey, payload, Date().timeIntervalSince1970])
// rowid tie-breaks equal timestamps: INSERT OR REPLACE mints a fresh
// rowid, so the most recently written transcript always survives.
self.execute(
db,
sql: """
DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key NOT IN (
SELECT session_key FROM cached_transcripts WHERE gateway_id = ?1
ORDER BY updated_at DESC, rowid DESC LIMIT \(Self.maxCachedTranscripts)
)
""",
bindings: [self.gatewayID])
}
public func retire() async {
// A queued write then either finishes before retirement or becomes a
// no-op. Closing the handle lets the owner delete the whole cache file.
self.isRetired = true
self.db = nil
}
// MARK: - Cached shapes
/// Text rows only in v1: strip attachment/binary payloads and tool
/// arguments so the cache never persists base64 blobs or large payloads.
static func cacheableMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
messages.suffix(self.maxCachedMessagesPerSession).map { message in
OpenClawChatMessage(
id: message.id,
role: message.role,
content: message.content.map { item in
OpenClawChatMessageContent(
type: item.type,
text: item.text,
thinking: item.thinking,
thinkingSignature: nil,
mimeType: item.mimeType,
fileName: item.fileName,
content: nil,
id: item.id,
name: item.name,
arguments: nil)
},
timestamp: message.timestamp,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
}
static func boundedSessions(_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
guard sessions.count > self.maxCachedSessions else { return sessions }
return Array(
sessions
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
.prefix(self.maxCachedSessions))
}
private static func encodeJSON(_ value: some Encodable) -> String? {
guard let data = try? JSONEncoder().encode(value) else { return nil }
return String(bytes: data, encoding: .utf8)
}
// MARK: - Connection lifecycle
private func handle() async -> OpaquePointer? {
guard !self.isRetired else { return nil }
if let db { return db.raw }
if self.isBroken { return nil }
#if os(iOS)
// Complete protection intentionally makes the cache unavailable while
// locked. Treat that as a temporary miss, never as corruption.
guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil }
#endif
if let opened = self.openConnection() {
self.db = Connection(raw: opened)
return opened
}
#if os(iOS)
guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil }
#endif
// Cache is disposable: on any open/schema failure drop the file
// (and SQLite sidecars) and rebuild once, silently.
self.removeDatabaseFiles()
if let reopened = self.openConnection() {
self.db = Connection(raw: reopened)
return reopened
}
cacheLogger.error("chat transcript cache unavailable; continuing without offline cache")
self.isBroken = true
return nil
}
#if os(iOS)
private func isProtectedDataAvailable() async -> Bool {
await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
}
#endif
private func openConnection() -> OpaquePointer? {
let fm = FileManager.default
try? fm.createDirectory(
at: self.databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
var opened: OpaquePointer?
var flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
#if os(iOS)
// Apply Complete protection through the SQLite VFS so auxiliary files
// receive the same class as the main transcript database.
flags |= SQLITE_OPEN_FILEPROTECTION_COMPLETE
#endif
guard sqlite3_open_v2(self.databaseURL.path, &opened, flags, nil) == SQLITE_OK, let opened else {
sqlite3_close_v2(opened)
return nil
}
guard let version = self.readUserVersion(opened) else {
sqlite3_close_v2(opened)
return nil
}
if version == 0 {
guard self.createSchema(opened) else {
sqlite3_close_v2(opened)
return nil
}
} else if version != Self.schemaVersion {
// Unknown schema: no migrations by design, force drop-and-rebuild.
sqlite3_close_v2(opened)
return nil
}
#if os(iOS)
// Upgrade a database created by an older build to the stricter class.
try? fm.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: self.databaseURL.path)
#endif
return opened
}
private func readUserVersion(_ db: OpaquePointer) -> Int32? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK else {
return nil
}
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
return sqlite3_column_int(statement, 0)
}
private func createSchema(_ db: OpaquePointer) -> Bool {
let statements = [
"""
CREATE TABLE IF NOT EXISTS cached_sessions(
gateway_id TEXT NOT NULL PRIMARY KEY,
payload TEXT NOT NULL,
updated_at REAL NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS cached_transcripts(
gateway_id TEXT NOT NULL,
session_key TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY(gateway_id, session_key)
)
""",
"PRAGMA user_version = \(Self.schemaVersion)",
]
for sql in statements {
guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return false }
}
return true
}
// MARK: - Statement helpers
@discardableResult
private func execute(_ db: OpaquePointer, sql: String, bindings: [Any]) -> Bool {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
cacheLogger.error("cache statement prepare failed")
return false
}
defer { sqlite3_finalize(statement) }
guard self.bind(statement, bindings: bindings) else { return false }
return sqlite3_step(statement) == SQLITE_DONE
}
private func selectPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> String? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(statement) }
guard self.bind(statement, bindings: bindings) else { return nil }
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
guard let text = sqlite3_column_text(statement, 0) else { return nil }
return String(cString: text)
}
private func bind(_ statement: OpaquePointer?, bindings: [Any]) -> Bool {
// SQLITE_TRANSIENT: sqlite copies the buffer before the Swift string dies.
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
for (offset, value) in bindings.enumerated() {
let index = Int32(offset + 1)
let result: Int32 = switch value {
case let text as String:
sqlite3_bind_text(statement, index, text, -1, transient)
case let real as Double:
sqlite3_bind_double(statement, index, real)
default:
SQLITE_MISUSE
}
guard result == SQLITE_OK else { return false }
}
return true
}
private func removeDatabaseFiles() {
self.db = nil
Self.removeDatabaseFiles(at: self.databaseURL)
}
}