mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 06:30:19 +08:00
* feat(ios): durable offline command outbox for chat sends
Text messages sent while the gateway is unreachable queue in a durable
per-gateway outbox (new outbox_commands table in the per-gateway chat
cache SQLite store, schema v2) instead of failing. Queued bubbles render
with visible Queued/Sending/Not sent states and flush strictly in
createdAt order once transport health recovers; each command's client
UUID rides as the send idempotency key, so at-least-once delivery plus
gateway dedupe keeps the transcript exact.
Contract summary:
- Bounds: 50 queued commands per gateway; refused enqueues keep the
draft. Queued rows older than 48h expire to failed("expired") rather
than silently sending stale commands; tap-to-retry refreshes
createdAt so an expired row can resend as new intent.
- Failure taxonomy: transport-level failures keep rows queued without
burning retry attempts (backoff ladder, then health drops so the
reconnect machinery owns pacing); gateway rejections burn attempts
and fail terminally after 3, with context-menu retry/delete.
- Deletes are tombstoned synchronously and rechecked after the claim
await so an active flush can never send a removed command; Delete is
hidden while a bubble is already in flight; offline enqueue is
guarded against double submit during the health probe.
- Post-reconnect live sends route behind draining outbox rows (FIFO),
and a cold open assumes a backlog until restore adopts durable rows.
- Crash safety: 'sending' rows revert to 'queued' at startup; flushed
turns are spliced into the session's cached transcript before their
outbox row is deleted, and stale history snapshots cannot evict a
just-flushed turn until a snapshot confirms it.
- Per-gateway scoping and purge ride the transcript cache: one SQLite
file per gateway; reset/forget drops the queue with the cache.
Fixture/unpaired transports get no outbox.
Part of #46664
* chore(ios): sync native i18n inventory
* style(chat-ui): satisfy strict SwiftFormat lint (doc comment, scope blank line)
134 lines
5.2 KiB
Swift
134 lines
5.2 KiB
Swift
import Foundation
|
|
|
|
extension OpenClawChatViewModel {
|
|
public var sessionChoices: [OpenClawChatSessionEntry] {
|
|
let now = Date().timeIntervalSince1970 * 1000
|
|
let cutoff = now - (24 * 60 * 60 * 1000)
|
|
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
|
let mainSessionKey = self.resolvedMainSessionKey
|
|
|
|
var result: [OpenClawChatSessionEntry] = []
|
|
var included = Set<String>()
|
|
|
|
// Always show the resolved main session first, even if it hasn't been updated recently.
|
|
if let main = sorted.first(where: { $0.key == mainSessionKey }) {
|
|
result.append(main)
|
|
included.insert(main.key)
|
|
} else {
|
|
result.append(self.placeholderSession(key: mainSessionKey))
|
|
included.insert(mainSessionKey)
|
|
}
|
|
|
|
for entry in sorted {
|
|
guard !included.contains(entry.key) else { continue }
|
|
guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue }
|
|
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
|
result.append(entry)
|
|
included.insert(entry.key)
|
|
}
|
|
|
|
if !included.contains(self.sessionKey) {
|
|
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
|
|
result.append(current)
|
|
} else {
|
|
result.append(self.placeholderSession(key: self.sessionKey))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private static func isHiddenInternalSession(_ key: String) -> Bool {
|
|
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return false }
|
|
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
|
|
}
|
|
|
|
func matchesCurrentSessionKey(incoming: String, current: String) -> Bool {
|
|
Self.matchesCurrentSessionKey(
|
|
incoming: incoming,
|
|
current: current,
|
|
mainSessionKey: resolvedMainSessionKey,
|
|
activeAgentId: activeAgentId)
|
|
}
|
|
|
|
func matchesCurrentSessionKey(incoming: String, agentId: String?, current: String) -> Bool {
|
|
Self.matchesCurrentSessionKey(
|
|
incoming: incoming,
|
|
agentId: agentId,
|
|
current: current,
|
|
mainSessionKey: resolvedMainSessionKey,
|
|
activeAgentId: activeAgentId)
|
|
}
|
|
|
|
static func matchesCurrentSessionKey(
|
|
incoming: String,
|
|
agentId: String? = nil,
|
|
current: String,
|
|
mainSessionKey: String,
|
|
activeAgentId: String? = nil)
|
|
-> Bool
|
|
{
|
|
let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if incomingNormalized == currentNormalized {
|
|
if incomingNormalized == "global" {
|
|
return Self.matchesGlobalAgent(agentId: agentId, activeAgentId: activeAgentId)
|
|
}
|
|
return true
|
|
}
|
|
|
|
let mainNormalized = mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if Self.matchesMainAlias(
|
|
incoming: incomingNormalized,
|
|
current: currentNormalized,
|
|
mainSessionKey: mainNormalized)
|
|
{
|
|
if incomingNormalized == "global" || currentNormalized == "global" || mainNormalized == "global" {
|
|
return Self.matchesGlobalAgent(agentId: agentId, activeAgentId: activeAgentId)
|
|
}
|
|
return true
|
|
}
|
|
if Self.matchesSelectedAgentGlobal(
|
|
incoming: incomingNormalized,
|
|
agentId: agentId,
|
|
current: currentNormalized)
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func normalizedAgentId(_ agentId: String?) -> String? {
|
|
let normalized = agentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return normalized?.isEmpty == false ? normalized : nil
|
|
}
|
|
|
|
private static func matchesGlobalAgent(agentId: String?, activeAgentId: String?) -> Bool {
|
|
guard let activeAgentId = normalizedAgentId(activeAgentId) else { return false }
|
|
guard let incomingAgentId = normalizedAgentId(agentId) else { return true }
|
|
return incomingAgentId == activeAgentId
|
|
}
|
|
|
|
private static func matchesMainAlias(incoming: String, current: String, mainSessionKey: String) -> Bool {
|
|
if current == "main", incoming == mainSessionKey, mainSessionKey != "main" {
|
|
return true
|
|
}
|
|
if incoming == "main", current == mainSessionKey, mainSessionKey != "main" {
|
|
return true
|
|
}
|
|
return (current == "main" && incoming == "agent:main:main") ||
|
|
(incoming == "main" && current == "agent:main:main")
|
|
}
|
|
|
|
private static func matchesSelectedAgentGlobal(incoming: String, agentId: String?, current: String) -> Bool {
|
|
guard incoming == "global",
|
|
let selectedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
!selectedAgentId.isEmpty
|
|
else {
|
|
return false
|
|
}
|
|
return current == "agent:\(selectedAgentId):global"
|
|
}
|
|
}
|