Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TranscriptCache.swift
Peter Steinberger df9a940872 feat(chat): show live run telemetry across clients (#113084)
* feat(chat): show live run telemetry across clients

* fix(chat): satisfy native validation gates

* fix(chat): settle advertised terminal runs

* fix(android): clear stale run usage on gaps

* chore(i18n): align native source inventory

* fix(chat): reconcile telemetry with current projections

* refactor(chat): keep run state in owner extensions

* chore(i18n): refresh native source inventory

* test(ui): expect persistent working telemetry through streaming
2026-07-29 01:27:58 -04:00

146 lines
6.3 KiB
Swift

import Foundation
// Offline transcript cache integration. The cache only pre-paints cold opens
// and covers offline browsing; live gateway responses are always the source
// of truth and replace cached rows wholesale.
extension OpenClawChatViewModel {
struct SessionSnapshot: Equatable {
var key: String
var generation: UInt64
var agentID: String?
var deliveryAgentID: String?
var sessionRoutingContract: String?
}
func replaceMessages(_ messages: [OpenClawChatMessage]) {
guard self.messages != messages else { return }
self.messages = messages
self.seedInputHistory(from: messages)
markTimelineChanged()
}
nonisolated static func durableSessionCacheProjection(
_ session: OpenClawChatSessionEntry) -> OpenClawChatSessionEntry
{
var projected = session
let wasActive =
projected.hasActiveRun == true ||
projected.activeRunIds?.isEmpty == false ||
projected.hasActiveSubagentRun == true ||
projected.status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "running"
projected.hasActiveRun = nil
projected.activeRunIds = nil
projected.hasActiveSubagentRun = nil
if wasActive {
projected.startedAt = nil
projected.status = nil
}
return projected
}
func persistTranscriptToCache(
session: SessionSnapshot,
messages: [OpenClawChatMessage],
canonicalMessageIdempotencyKeys: Set<String>)
{
let transcriptCache = self.transcriptCache
let outbox = self.outbox
let scope = self.outboxBranchScope(for: session)
let tip = messages.reversed().compactMap { message -> String? in
let entryID = message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines)
return entryID?.isEmpty == false ? entryID : nil
}.first
guard transcriptCache != nil || (outbox != nil && scope != nil && tip != nil) else { return }
let branchStateTask: Task<OpenClawChatOutboxBranchState?, Never>? = if let outbox, let scope {
Task { await outbox.branchState(for: scope) }
} else {
nil
}
// Chain writes so an older snapshot can never land after a newer one;
// detached tasks alone give no ordering guarantee across awaits.
let previous = pendingCacheWriteTask
pendingCacheWriteTask = Task.detached {
await previous?.value
if let transcriptCache {
await transcriptCache.storeCanonicalTranscript(
sessionKey: session.key,
agentID: Self.transcriptCacheAgentID(sessionKey: session.key, agentID: session.agentID),
messages: messages,
canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys)
}
if let outbox,
let scope,
let tip,
let branchStateTask,
let expectedEpoch = await branchStateTask.value?.epoch
{
_ = await outbox.updateLastActiveLeafEntryID(
tip,
expectedEpoch: expectedEpoch,
for: scope)
}
}
}
func persistSessionsToCache(_ sessions: [OpenClawChatSessionEntry]) {
guard let transcriptCache else { return }
let durableSessions = sessions.map(Self.durableSessionCacheProjection)
let previous = pendingCacheWriteTask
pendingCacheWriteTask = Task.detached {
await previous?.value
await transcriptCache.storeSessions(durableSessions)
}
}
/// Cache-first cold open: pre-paint the cached transcript/session list
/// while the live requests are in flight (or failing while offline).
/// Live history replaces the painted rows wholesale via the normal
/// applyHistoryPayload reconciliation path.
func paintFromCacheIfNeeded(session: SessionSnapshot) {
guard let transcriptCache else { return }
if sessions.isEmpty, !hasAppliedLiveSessions {
Task { [weak self] in
let cached = await transcriptCache.loadSessions()
guard let self, !cached.isEmpty else { return }
// A live sessions response (even an empty one) is authoritative;
// a slow cache read must never repaint over it.
// Session lists are always agent-scoped, even when the chat key
// itself has immutable routing and ignores active-agent changes.
guard self.isCurrentSession(session),
self.activeAgentId == session.agentID,
self.sessions.isEmpty,
!self.hasAppliedLiveSessions
else {
return
}
let durableSessions = cached.map(Self.durableSessionCacheProjection)
let organized = OpenClawChatSessionListOrganizer.organize(durableSessions)
let scoped = ChatSessionSidebarModel.clearingForeignGlobalObserverDigest(
in: organized,
activeAgentId: self.activeAgentId)
self.sessions = self.applyingLocalUnreadOverrides(
to: scoped)
}
}
guard messages.isEmpty, !hasAppliedLiveHistory else { return }
Task { [weak self] in
let cached = await transcriptCache.loadTranscript(
sessionKey: session.key,
agentID: Self.transcriptCacheAgentID(sessionKey: session.key, agentID: session.agentID))
guard let self, !cached.isEmpty else { return }
guard self.isCurrentSession(session), !self.hasAppliedLiveHistory, self.messages.isEmpty else {
return
}
self.replaceMessages(cached)
self.isShowingCachedTranscript = true
}
}
static func transcriptCacheAgentID(sessionKey: String, agentID: String?) -> String? {
guard OpenClawChatSessionKey.agentID(from: sessionKey) == nil else { return nil }
let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
}