mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 23:31:34 +00:00
* 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
146 lines
6.3 KiB
Swift
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
|
|
}
|
|
}
|