Files
openclaw/apps/ios/Sources/Model/WatchReplyCoordinator.swift
Peter Steinberger 079ac9390d refactor(ios): simplify node and Watch lifecycle ownership (#114366)
* refactor(ios): centralize node lifecycle owners

* fix(ios): sync native localization source inventory

* test(ios): prove gateway and notification lifecycles in simulator

* fix(ios): preserve voice upgrade and correlate watch snapshots
2026-07-27 03:23:31 -04:00

387 lines
16 KiB
Swift

import CryptoKit
import Foundation
import OpenClawChatUI
import OpenClawKit
/// Three recovery sources represent the same gateway-owned approval readback.
/// Preserve their source so cached cards, migration rows, and held Watch actions
/// share one classifier without losing source-specific cleanup.
enum WatchApprovalReadbackCandidate<Prompt, PersistedReadback> {
case cached(Prompt)
case persisted(PersistedReadback)
case held(WatchExecApprovalSnapshotRequestItem)
}
/// Owns the one canonical interpretation of projected chat rows shared by Watch
/// previews and run-correlated voice replies.
enum WatchChatPresentation {
private static let previewItemLimit = 5
private struct MetadataEnvelope: Decodable {
struct Metadata: Decodable {
var id: String?
}
var metadata: Metadata?
var messageToolMirror: [String: String]?
enum CodingKeys: String, CodingKey {
case metadata = "__openclaw"
case messageToolMirror = "openclawMessageToolMirror"
}
}
private struct MessageEntry {
var message: OpenClawChatMessage
var text: String
var serverID: String?
var isMessageToolMirror: Bool
}
nonisolated static func replyText(
from rawMessages: [OpenClawKit.AnyCodable],
runID: String,
submittedText: String,
submittedAtMs: Int64) -> String?
{
let entries = rawMessages.compactMap(Self.decodeMessage)
if let directReply = entries.last(where: {
Self.isTerminalAssistant($0) && $0.message.idempotencyKey == runID
}) {
return directReply.text
}
let userIdempotencyKey = "\(runID):user"
let exactUserIndex = entries.lastIndex(where: {
$0.message.role.lowercased() == "user" &&
$0.message.idempotencyKey == userIdempotencyKey
})
let queuedUserIndex = entries.lastIndex(where: { entry in
guard entry.message.role.lowercased() == "user",
let timestampMs = Self.timestampMs(entry.message.timestamp),
timestampMs >= submittedAtMs
else { return false }
return entry.text.contains(submittedText)
})
guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil }
return entries[(userIndex + 1)...].first(where: Self.isTerminalAssistant)?.text
}
nonisolated static func makeItems(
from rawMessages: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
{
var occurrences: [String: Int] = [:]
let identified = rawMessages.compactMap(Self.decodeMessage).map { entry in
let baseID = entry.serverID.map { "\(entry.message.role)-\($0)" }
?? Self.fallbackKey(entry)
occurrences[baseID, default: 0] += 1
return (entry, "\(baseID)-\(occurrences[baseID]!)")
}
return identified.suffix(Self.previewItemLimit).map { entry, stableID in
OpenClawWatchChatItem(
id: stableID,
role: entry.message.role,
text: Self.truncatedText(entry.text),
timestampMs: Self.timestampMs(entry.message.timestamp))
}
}
private nonisolated static func isTerminalAssistant(_ entry: MessageEntry) -> Bool {
guard entry.message.role.lowercased() == "assistant" else { return false }
if entry.isMessageToolMirror { return true }
guard let stopReason = entry.message.stopReason?.lowercased() else { return false }
// Progress rows are not final replies; the later assistant row owns completion.
return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls"
}
private nonisolated static func decodeMessage(_ raw: OpenClawKit.AnyCodable) -> MessageEntry? {
guard let data = try? JSONEncoder().encode(raw),
let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data),
let text = nonEmptyText(messageText(message))
else { return nil }
let metadata = try? JSONDecoder().decode(MetadataEnvelope.self, from: data)
return MessageEntry(
message: message,
text: text,
serverID: metadata?.metadata?.id,
isMessageToolMirror: metadata?.messageToolMirror != nil)
}
private nonisolated static func fallbackKey(_ entry: MessageEntry) -> String {
let timestamp = Self.timestampMs(entry.message.timestamp).map(String.init) ?? "missing"
let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)"
let digest = SHA256.hash(data: Data(source.utf8))
.map { String(format: "%02x", $0) }
.joined()
return "\(entry.message.role)-\(digest)"
}
private nonisolated static func messageText(_ message: OpenClawChatMessage) -> String {
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil }
if let text = Self.nonEmptyText(content.text) { return text }
if let text = Self.nonEmptyText(content.content?.value as? String) { return text }
if let values = content.content?.value as? [String: OpenClawKit.AnyCodable] {
return Self.nonEmptyText(values["text"]?.value as? String)
}
return nil
}
let contentText = parts.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return contentText.isEmpty
? message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
: contentText
}
private nonisolated static func nonEmptyText(_ text: String?) -> String? {
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private nonisolated static func truncatedText(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > 240 else { return trimmed }
return "\(trimmed.prefix(237))..."
}
private nonisolated static func timestampMs(_ timestamp: Double?) -> Int64? {
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
guard milliseconds.isFinite,
milliseconds >= 0,
milliseconds <= 32_503_680_000_000
else { return nil }
return Int64(milliseconds)
}
}
@MainActor
final class WatchMessageOutbox {
enum Decision {
case dropMissingFields
case dropMissingTarget
case deduped(messageID: String)
case queue(messageID: String)
case forward
}
// Keep the shipped chat key so upgrades retain messages already queued by the Watch.
private static let persistedQueueKey = "watch.chat.command.queue.v1"
private static let persistedMetadataKey = "watch.message.outbox.metadata.v1"
private static let maxRecentMessageIDs = 128
private static let maxPromptRoutes = 128
private struct QueuedMessage: Codable, Equatable {
var gatewayStableID: String
var event: WatchAppCommandEvent
}
private struct PromptRoute: Codable, Equatable {
var promptID: String
var gatewayStableID: String
}
private struct PersistedMetadata: Codable, Equatable {
var recentMessageIDs: [String]
var promptRoutes: [PromptRoute]
}
private let defaults: UserDefaults
private var queuedMessages: [QueuedMessage] = []
private var recentMessageIDs: [String] = []
private var seenMessageIDs = Set<String>()
private var promptRoutes: [PromptRoute] = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.restoreMetadata()
self.restoreQueue()
}
func ingest(
_ event: WatchAppCommandEvent,
isAvailable: Bool,
gatewayStableID: String?) -> Decision
{
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if messageID.isEmpty || text.isEmpty {
return .dropMissingFields
}
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty else { return .dropMissingTarget }
if self.seenMessageIDs.contains(messageID) {
return .deduped(messageID: messageID)
}
// Persist before network delivery; iOS may suspend a background callback at any await.
self.queuedMessages.append(
QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)))
self.rebuildSeenMessageIDs()
self.persistQueue()
return isAvailable ? .forward : .queue(messageID: messageID)
}
func recordPromptRoute(promptID: String?, gatewayStableID: String?) {
let promptID = promptID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { return }
self.promptRoutes.removeAll { $0.promptID == promptID }
self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID))
if self.promptRoutes.count > Self.maxPromptRoutes {
self.promptRoutes.removeFirst(self.promptRoutes.count - Self.maxPromptRoutes)
}
self.persistMetadata()
}
func gatewayStableID(forPromptID promptID: String) -> String? {
let promptID = promptID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !promptID.isEmpty, promptID != "unknown" else { return nil }
return self.promptRoutes.last { $0.promptID == promptID }?.gatewayStableID
}
func nextQueuedMessage(isAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard isAvailable, !owner.isEmpty else { return nil }
// Replies are time-sensitive; a retrying chat must not strand them behind it.
if let reply = self.queuedMessages.first(where: {
$0.gatewayStableID == owner && self.kind(of: $0.event) == .quickReply
}) {
return reply.event
}
return self.queuedMessages.first { $0.gatewayStableID == owner }?.event
}
func removeQueuedMessage(messageID: String, gatewayStableID: String?) {
let messageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !messageID.isEmpty, !owner.isEmpty else { return }
guard let index = self.queuedMessages.firstIndex(where: {
$0.gatewayStableID == owner && $0.event.commandId == messageID
}) else { return }
self.queuedMessages.remove(at: index)
self.rememberRecentMessageID(messageID)
self.persistQueue()
}
func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) {
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !messageID.isEmpty, !owner.isEmpty else { return }
self.queuedMessages.removeAll { $0.event.commandId == messageID }
self.queuedMessages.insert(
QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)),
at: 0)
self.rebuildSeenMessageIDs()
self.persistQueue()
}
func queuedCount(kind: WatchMessageKind? = nil) -> Int {
guard let kind else { return self.queuedMessages.count }
return self.queuedMessages.count(where: { self.kind(of: $0.event) == kind })
}
func queuedMessageIDs(kind: WatchMessageKind? = nil) -> [String] {
self.queuedMessages.compactMap { queued in
guard kind == nil || self.kind(of: queued.event) == kind else { return nil }
return queued.event.commandId
}
}
private func restoreQueue() {
guard let data = defaults.data(forKey: Self.persistedQueueKey),
let persisted = try? JSONDecoder().decode([QueuedMessage].self, from: data)
else {
return
}
var seenSet = Set<String>()
self.queuedMessages = persisted.compactMap { queued in
let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let messageID = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty, !messageID.isEmpty, !text.isEmpty, seenSet.insert(messageID).inserted else {
return nil
}
return QueuedMessage(gatewayStableID: owner, event: self.message(queued.event, taggedFor: owner))
}
self.rebuildSeenMessageIDs()
if self.queuedMessages.count != persisted.count {
self.persistQueue()
}
}
private func rememberRecentMessageID(_ messageID: String) {
guard !messageID.isEmpty else { return }
self.recentMessageIDs.removeAll { $0 == messageID }
self.recentMessageIDs.append(messageID)
if self.recentMessageIDs.count > Self.maxRecentMessageIDs {
self.recentMessageIDs.removeFirst(self.recentMessageIDs.count - Self.maxRecentMessageIDs)
}
self.rebuildSeenMessageIDs()
self.persistMetadata()
}
private func restoreMetadata() {
guard let data = self.defaults.data(forKey: Self.persistedMetadataKey),
let metadata = try? JSONDecoder().decode(PersistedMetadata.self, from: data)
else { return }
for rawMessageID in metadata.recentMessageIDs {
let messageID = rawMessageID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !messageID.isEmpty else { continue }
self.recentMessageIDs.removeAll { $0 == messageID }
self.recentMessageIDs.append(messageID)
}
self.recentMessageIDs = Array(self.recentMessageIDs.suffix(Self.maxRecentMessageIDs))
for rawRoute in metadata.promptRoutes {
let promptID = rawRoute.promptID.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = rawRoute.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { continue }
self.promptRoutes.removeAll { $0.promptID == promptID }
self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID))
}
self.promptRoutes = Array(self.promptRoutes.suffix(Self.maxPromptRoutes))
self.rebuildSeenMessageIDs()
}
private func rebuildSeenMessageIDs() {
var ids = Set(self.recentMessageIDs)
ids.formUnion(self.queuedMessages.map(\.event.commandId))
self.seenMessageIDs = ids
}
private func persistQueue() {
if self.queuedMessages.isEmpty {
self.defaults.removeObject(forKey: Self.persistedQueueKey)
return
}
guard let data = try? JSONEncoder().encode(self.queuedMessages) else { return }
self.defaults.set(data, forKey: Self.persistedQueueKey)
}
private func persistMetadata() {
let metadata = PersistedMetadata(
recentMessageIDs: self.recentMessageIDs,
promptRoutes: self.promptRoutes)
guard let data = try? JSONEncoder().encode(metadata) else { return }
self.defaults.set(data, forKey: Self.persistedMetadataKey)
}
private func message(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
var tagged = event
tagged.gatewayStableID = gatewayStableID
return tagged
}
private func kind(of event: WatchAppCommandEvent) -> WatchMessageKind {
event.messageKind ?? .chat
}
static func resetPersistedQueue(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: self.persistedQueueKey)
defaults.removeObject(forKey: self.persistedMetadataKey)
}
}