fix(ios): persist queued Watch replies (#100372)

* fix(ios): persist queued watch replies

Route Apple Watch quick replies through the durable, gateway-scoped Watch message outbox with restart-safe routing and deduplication.

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): preserve pre-upgrade Watch replies

Bind shipped notification prompts without routing metadata to the current Gateway once, while keeping explicit and persisted Gateway owners fail-closed.

---------

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-05 09:14:49 -07:00
committed by GitHub
parent f607ba0691
commit 70f2bef6b4
11 changed files with 721 additions and 284 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- **Model aliases:** resolve provider-qualified aliases during session and chat-command model switches without collisions when providers share a display alias. (#100209) Thanks @sahilsatralkar.
- **TUI new-session hooks:** create `/new` sessions through the shared Gateway lifecycle so command and session hooks receive the completed parent transcript in both Gateway and embedded modes, while preventing rollover during an active turn. (#100241, #49918) Thanks @BingqingLyu.
- **TUI abort diagnostics:** show sanitized tool argument-validation summaries for aborted runs in both Gateway and local TUI modes without exposing raw model arguments. (#91002) Thanks @wsyjh8.
- **iOS Watch replies:** persist queued quick replies in the gateway-scoped chat outbox and submit them through idempotent chat delivery, preventing losses, duplicates, and cross-gateway sends after reconnects. (#100031) Thanks @NianJiuZst.
## 2026.7.1

View File

@@ -10995,7 +10995,7 @@
},
{
"kind": "conditional-branch",
"line": 2319,
"line": 2343,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Action required",
"surface": "apple",
@@ -11003,7 +11003,7 @@
},
{
"kind": "conditional-branch",
"line": 2319,
"line": 2343,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval needed",
"surface": "apple",
@@ -11011,7 +11011,7 @@
},
{
"kind": "conditional-branch",
"line": 2652,
"line": 2676,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting…",
"surface": "apple",
@@ -11019,7 +11019,7 @@
},
{
"kind": "conditional-branch",
"line": 2652,
"line": 2676,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting…",
"surface": "apple",
@@ -11027,7 +11027,7 @@
},
{
"kind": "conditional-branch",
"line": 2656,
"line": 2680,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting...",
"surface": "apple",
@@ -11035,7 +11035,7 @@
},
{
"kind": "conditional-branch",
"line": 2656,
"line": 2680,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting...",
"surface": "apple",
@@ -11043,7 +11043,7 @@
},
{
"kind": "conditional-branch",
"line": 2964,
"line": 2988,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connected",
"surface": "apple",
@@ -11051,7 +11051,7 @@
},
{
"kind": "conditional-branch",
"line": 2964,
"line": 2988,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Offline",
"surface": "apple",

View File

@@ -50,6 +50,11 @@ private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
private struct NodeEventRequestPayload: Encodable {
var event: String
var payloadJSON: String
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -87,6 +92,7 @@ private enum IOSDeepLinkAgentPolicy {
// swiftlint:disable type_body_length file_length
final class NodeAppModel {
private nonisolated static let watchChatPreviewItemLimit = 5
private nonisolated static let watchMessageMaxImmediateRetryAttempts = 3
struct AgentDeepLinkPrompt: Identifiable, Equatable {
let id: String
@@ -122,12 +128,19 @@ final class NodeAppModel {
case failed(message: String)
}
private enum WatchMessageSendOutcome {
case sent
case retry
case discard
}
private struct PersistedWatchExecApprovalBridgeState: Codable {
var approvals: [ExecApprovalPrompt]
var pendingApprovalIDs: [String]?
}
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
private nonisolated static let agentRequestNodeEventTimeoutSeconds = 8
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
"notifications.execApprovalGuidance.suppressed"
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
@@ -225,6 +238,9 @@ final class NodeAppModel {
private let remindersService: any RemindersServicing
private let motionService: any MotionServicing
private let watchMessagingService: any WatchMessagingServicing
#if DEBUG
@ObservationIgnored private var testAgentRequestHandler: ((AgentDeepLink) async throws -> Void)?
#endif
private var pttVoiceWakeSuspended = false
private var talkVoiceWakeSuspended = false
private var backgroundVoiceWakeSuspended = false
@@ -238,8 +254,10 @@ final class NodeAppModel {
private var backgroundReconnectLeaseUntil: Date?
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
@ObservationIgnored private let watchChatCoordinator = WatchChatCoordinator()
@ObservationIgnored private let watchMessageOutbox = WatchMessageOutbox()
@ObservationIgnored private var watchMessageFlushInFlight = false
@ObservationIgnored private var watchMessageRetryAttempts: [String: Int] = [:]
@ObservationIgnored private var watchMessageRetryTask: Task<Void, Never>?
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
private var pendingWatchExecApprovalRecoveryIDs: [String] = []
@@ -1904,15 +1922,21 @@ extension NodeAppModel {
message: "INVALID_REQUEST: empty watch notification"))
}
do {
let gatewayStableID = self.currentWatchChatGatewayStableID()
self.watchMessageOutbox.recordPromptRoute(
promptID: normalizedParams.promptId,
gatewayStableID: gatewayStableID)
let result = try await self.watchMessagingService.sendNotification(
id: req.id,
params: normalizedParams)
params: normalizedParams,
gatewayStableID: gatewayStableID)
if result.queuedForDelivery || !result.deliveredImmediately {
let invokeID = req.id
Task { @MainActor in
await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded(
invokeID: invokeID,
params: normalizedParams,
gatewayStableID: gatewayStableID,
sendResult: result)
}
}
@@ -2971,7 +2995,7 @@ extension NodeAppModel {
return
}
Task { [weak self] in
await self?.flushQueuedWatchChatsIfAvailable()
await self?.flushQueuedWatchMessagesIfAvailable()
guard changed else { return }
await self?.syncWatchAppSnapshot(reason: "operator_online")
}
@@ -3180,7 +3204,6 @@ extension NodeAppModel {
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
await self.flushQueuedWatchRepliesIfConnected()
await self.syncWatchAppSnapshot(reason: "node_connected", includeChat: true)
await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
@@ -3263,53 +3286,61 @@ extension NodeAppModel {
}
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
switch await self.watchReplyCoordinator.ingest(event, isGatewayConnected: self.isGatewayConnected()) {
case .dropMissingFields:
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionID = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !replyID.isEmpty, !actionID.isEmpty else {
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
case let .deduped(replyId):
self.watchReplyLogger.debug(
"watch reply deduped replyId=\(replyId, privacy: .public)")
case let .queue(replyId, actionId):
self.watchReplyLogger.info(
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
case .forward:
await self.forwardWatchReplyToAgent(event)
return
}
}
let payloadGatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let currentGatewayID = self.currentWatchChatGatewayStableID()
let routedGatewayID = self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId) ?? ""
let sourceGatewayID: String
if !payloadGatewayID.isEmpty {
sourceGatewayID = payloadGatewayID
} else if !routedGatewayID.isEmpty {
sourceGatewayID = routedGatewayID
} else if let currentGatewayID {
// Shipped prompts predate gateway routing metadata and cannot be migrated after delivery.
// Bind that prompt once; explicit or persisted owners never use this fallback.
sourceGatewayID = currentGatewayID
self.watchMessageOutbox.recordPromptRoute(
promptID: event.promptId,
gatewayStableID: currentGatewayID)
} else {
sourceGatewayID = ""
}
if !sourceGatewayID.isEmpty, let currentGatewayID, currentGatewayID != sourceGatewayID {
self.watchReplyLogger.info("watch reply dropped: stale gateway target")
return
}
guard !sourceGatewayID.isEmpty else {
self.watchReplyLogger.info("watch reply dropped: unresolved gateway target")
return
}
let gatewayStableID = sourceGatewayID
private func flushQueuedWatchRepliesIfConnected() async {
for event in await self.watchReplyCoordinator.drainIfConnected(self.isGatewayConnected()) {
await self.forwardWatchReplyToAgent(event)
}
}
let message = WatchAppCommandEvent(
commandId: replyID,
command: .sendChat,
sessionKey: event.sessionKey,
gatewayStableID: gatewayStableID,
text: Self.makeWatchReplyAgentMessage(event),
sentAtMs: event.sentAtMs,
transport: event.transport,
messageKind: .quickReply)
let needsReconnect = !self.isWatchMessageSendAvailable()
await self.handleWatchMessage(message)
guard needsReconnect else { return }
private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async {
let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey
let message = Self.makeWatchReplyAgentMessage(event)
let link = AgentDeepLink(
message: message,
sessionKey: effectiveSessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: event.replyId)
do {
try await self.sendAgentRequest(link: link)
let forwardedMessage =
"watch reply forwarded replyId=\(event.replyId) "
+ "action=\(event.actionId)"
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
self.openChatRequestID &+= 1
} catch {
let failedMessage =
"watch reply forwarding failed replyId=\(event.replyId) "
+ "error=\(error.localizedDescription)"
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
self.watchReplyCoordinator.requeueFront(event)
let connected = await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12000,
reason: "watch_reply")
guard connected, self.currentWatchChatGatewayStableID() == gatewayStableID else {
self.watchReplyLogger.info("watch reply remains queued: gateway target unavailable")
return
}
await self.flushQueuedWatchMessagesIfAvailable()
}
private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
@@ -3783,53 +3814,92 @@ extension NodeAppModel {
}
private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async {
guard self.watchChatCommandTargetsCurrentGateway(event) else {
guard self.watchMessageTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch chat send skipped: stale gateway target")
await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true)
return
}
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event)
switch self.watchChatCoordinator.ingest(
await self.handleWatchMessage(event)
}
private func handleWatchMessage(_ event: WatchAppCommandEvent) async {
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event)
let isAvailable = self.isWatchMessageSendAvailable()
if isAvailable, !self.watchMessageTargetsCurrentGateway(event) {
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
return
}
switch self.watchMessageOutbox.ingest(
event,
isChatAvailable: self.isWatchChatAvailableForSend(),
isAvailable: isAvailable,
gatewayStableID: eventGatewayID)
{
case .dropMissingFields:
GatewayDiagnostics.log("watch chat send skipped: missing commandId/text")
GatewayDiagnostics.log("watch message send skipped: missing id/text")
case .dropMissingTarget:
GatewayDiagnostics.log("watch chat send skipped: missing gateway target")
case let .deduped(commandId):
GatewayDiagnostics.log("watch chat send deduped commandId=\(commandId)")
case let .queue(commandId):
GatewayDiagnostics.log("watch chat send queued commandId=\(commandId)")
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
case .forward:
_ = await self.forwardWatchChatMessage(event, requeueOnFailure: true)
}
}
private func flushQueuedWatchChatsIfAvailable() async {
let gatewayStableID = self.currentWatchChatGatewayStableID()
while let event = self.watchChatCoordinator.nextQueuedCommand(
isChatAvailable: self.isWatchChatAvailableForSend(),
gatewayStableID: gatewayStableID)
{
guard self.watchChatCommandTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch chat send skipped: stale queued gateway target")
self.watchChatCoordinator.removeQueuedCommand(
commandId: event.commandId,
gatewayStableID: gatewayStableID)
continue
GatewayDiagnostics.log("watch message send skipped: missing gateway target")
case let .deduped(messageID):
GatewayDiagnostics.log("watch message send deduped id=\(messageID)")
case let .queue(messageID):
GatewayDiagnostics.log("watch message send queued id=\(messageID)")
if self.watchMessageKind(event) == .chat {
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
}
case .forward:
switch await self.forwardWatchMessage(event, requeueOnFailure: true) {
case .sent, .discard:
self.watchMessageOutbox.removeQueuedMessage(
messageID: event.commandId,
gatewayStableID: eventGatewayID)
self.watchMessageRetryAttempts[event.commandId] = nil
case .retry:
self.scheduleWatchMessageRetry(messageID: event.commandId)
}
let sent = await self.forwardWatchChatMessage(event, requeueOnFailure: false)
guard sent else { return }
self.watchChatCoordinator.removeQueuedCommand(
commandId: event.commandId,
gatewayStableID: gatewayStableID)
}
}
private func isWatchChatAvailableForSend() -> Bool {
private func flushQueuedWatchMessagesIfAvailable() async {
guard !self.watchMessageFlushInFlight else { return }
self.watchMessageFlushInFlight = true
defer { self.watchMessageFlushInFlight = false }
guard let gatewayStableID = self.currentWatchChatGatewayStableID() else { return }
while self.currentWatchChatGatewayStableID() == gatewayStableID {
guard let event = self.watchMessageOutbox.nextQueuedMessage(
isAvailable: self.isWatchMessageSendAvailable(),
gatewayStableID: gatewayStableID)
else { return }
guard self.watchMessageTargetsCurrentGateway(event) else { return }
switch await self.forwardWatchMessage(event, requeueOnFailure: false) {
case .sent, .discard:
self.watchMessageOutbox.removeQueuedMessage(
messageID: event.commandId,
gatewayStableID: gatewayStableID)
self.watchMessageRetryAttempts[event.commandId] = nil
case .retry:
self.scheduleWatchMessageRetry(messageID: event.commandId)
return
}
}
}
private func scheduleWatchMessageRetry(messageID: String) {
guard self.isWatchMessageSendAvailable(), self.watchMessageRetryTask == nil else { return }
let attempt = (self.watchMessageRetryAttempts[messageID] ?? 0) + 1
guard attempt <= Self.watchMessageMaxImmediateRetryAttempts else {
GatewayDiagnostics.log("watch message retry deferred until reconnect id=\(messageID)")
return
}
self.watchMessageRetryAttempts[messageID] = attempt
let delayNanoseconds = UInt64(500 * (1 << (attempt - 1))) * 1_000_000
self.watchMessageRetryTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: delayNanoseconds)
guard let self else { return }
self.watchMessageRetryTask = nil
await self.flushQueuedWatchMessagesIfAvailable()
}
}
private func isWatchMessageSendAvailable() -> Bool {
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
}
@@ -3837,32 +3907,45 @@ extension NodeAppModel {
self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func normalizedWatchChatGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
private func normalizedWatchMessageGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return gatewayStableID.isEmpty ? nil : gatewayStableID
}
private func watchChatCommandTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event) ?? ""
private func watchMessageTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event) ?? ""
let currentGatewayID = self.currentWatchChatGatewayStableID() ?? ""
guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false }
return eventGatewayID == currentGatewayID
}
private func forwardWatchChatMessage(
private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind {
event.messageKind ?? .chat
}
private func forwardWatchMessage(
_ event: WatchAppCommandEvent,
requeueOnFailure: Bool) async -> Bool
requeueOnFailure: Bool) async -> WatchMessageSendOutcome
{
guard self.watchMessageTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
return .retry
}
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else {
GatewayDiagnostics.log("watch chat send skipped: empty text")
return true
return .discard
}
let messageKind = self.watchMessageKind(event)
let fallbackSessionKey = messageKind == .quickReply ? self.mainSessionKey : self.chatSessionKey
let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? event.sessionKey!
: self.chatSessionKey
self.focusChatSession(sessionKey)
: fallbackSessionKey
if messageKind == .chat {
self.focusChatSession(sessionKey)
}
let thinking = messageKind == .quickReply ? "low" : "auto"
do {
let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000)
@@ -3870,9 +3953,13 @@ extension NodeAppModel {
let response = try await self.appleReviewDemoChatTransport.sendMessage(
sessionKey: sessionKey,
message: text,
thinking: "auto",
thinking: thinking,
idempotencyKey: event.commandId,
attachments: [])
if messageKind == .quickReply {
await self.finishForwardedWatchMessage(event)
return .sent
}
let history = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey)
if let replyText = Self.watchChatReplyText(
from: history.messages ?? [],
@@ -3883,17 +3970,17 @@ extension NodeAppModel {
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
}
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
return true
return .sent
}
guard self.isOperatorGatewayConnected else {
GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected")
if requeueOnFailure {
self.watchChatCoordinator.requeueFront(
self.watchMessageOutbox.requeueFront(
event,
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
}
return false
return .retry
}
let transport = IOSGatewayChatTransport(gateway: self.operatorSession)
@@ -3902,9 +3989,13 @@ extension NodeAppModel {
let response = try await transport.sendMessage(
sessionKey: sessionKey,
message: text,
thinking: "auto",
thinking: thinking,
idempotencyKey: event.commandId,
attachments: [])
if messageKind == .quickReply {
await self.finishForwardedWatchMessage(event)
return .sent
}
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
_ = await transport.waitForRunCompletion(
runId: response.runId,
@@ -3920,15 +4011,19 @@ extension NodeAppModel {
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
}
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
return true
return .sent
} catch {
GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)")
if requeueOnFailure {
self.watchChatCoordinator.requeueFront(
event,
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
if Self.shouldDiscardFailedWatchMessage(error) {
GatewayDiagnostics.log("watch message discarded after permanent send failure id=\(event.commandId)")
return .discard
}
return false
if requeueOnFailure {
self.watchMessageOutbox.requeueFront(
event,
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
}
return .retry
}
}
@@ -3969,6 +4064,22 @@ extension NodeAppModel {
}
}
private nonisolated static func shouldDiscardFailedWatchMessage(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
guard gatewayError.code == "INVALID_REQUEST" else { return false }
return !gatewayError.message.lowercased().hasSuffix("retry.")
}
private func finishForwardedWatchMessage(_ event: WatchAppCommandEvent) async {
if self.watchMessageKind(event) == .chat {
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
return
}
self.watchReplyLogger.info(
"watch reply forwarded replyId=\(event.commandId, privacy: .public)")
self.openChatRequestID &+= 1
}
private func syncWatchAppSnapshot(reason: String, includeChat: Bool = false) async {
let chatPreview = includeChat ? await self.makeWatchChatPreview() : nil
let message = self.makeWatchAppSnapshot(chatPreview: chatPreview)
@@ -5129,13 +5240,29 @@ extension NodeAppModel {
])
}
#if DEBUG
if let testAgentRequestHandler {
try await testAgentRequestHandler(link)
return
}
#endif
let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
])
}
await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json)
let requestData = try JSONEncoder().encode(NodeEventRequestPayload(event: "agent.request", payloadJSON: json))
guard let requestJSON = String(bytes: requestData, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request node event as UTF-8",
])
}
_ = try await self.nodeGateway.request(
method: "node.event",
paramsJSON: requestJSON,
timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds)
}
private func isGatewayConnected() async -> Bool {
@@ -5305,23 +5432,37 @@ extension NodeAppModel {
}
func _test_queuedWatchReplyCount() -> Int {
self.watchReplyCoordinator.queuedCount
self.watchMessageOutbox.queuedCount(kind: .quickReply)
}
func _test_queuedWatchChatCommandCount() -> Int {
self.watchChatCoordinator.queuedCount
self.watchMessageOutbox.queuedCount(kind: .chat)
}
func _test_queuedWatchChatCommandIds() -> [String] {
self.watchChatCoordinator.queuedCommandIds
self.watchMessageOutbox.queuedMessageIDs(kind: .chat)
}
func _test_recordWatchPromptRoute(promptID: String, gatewayStableID: String) {
self.watchMessageOutbox.recordPromptRoute(
promptID: promptID,
gatewayStableID: gatewayStableID)
}
func _test_setConnectedGatewayID(_ gatewayID: String?) {
self.connectedGatewayID = gatewayID
}
func _test_setAgentRequestHandler(_ handler: @escaping (AgentDeepLink) async throws -> Void) {
self.testAgentRequestHandler = handler
}
static func _test_resetPersistedWatchChatQueueState() {
WatchChatCoordinator.resetPersistedQueue()
WatchMessageOutbox.resetPersistedQueue()
}
static func _test_resetPersistedWatchReplyQueueState() {
WatchMessageOutbox.resetPersistedQueue()
}
func _test_setGatewayConnected(_ connected: Bool) {
@@ -5504,6 +5645,14 @@ extension NodeAppModel {
self.expectedDeepLinkKey()
}
nonisolated static func _test_shouldDiscardFailedWatchMessage(
code: String,
message: String = "test") -> Bool
{
self.shouldDiscardFailedWatchMessage(
GatewayResponseError(method: "chat.send", code: code, message: message, details: nil))
}
static func _test_resetPersistedWatchExecApprovalBridgeState() {
UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
}

View File

@@ -1,203 +1,230 @@
import Foundation
@MainActor
final class WatchReplyCoordinator {
enum Decision {
case dropMissingFields
case deduped(replyId: String)
case queue(replyId: String, actionId: String)
case forward
}
private var queuedReplies: [WatchQuickReplyEvent] = []
private var seenReplyIds = Set<String>()
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
if replyId.isEmpty || actionId.isEmpty {
return .dropMissingFields
}
if self.seenReplyIds.contains(replyId) {
return .deduped(replyId: replyId)
}
self.seenReplyIds.insert(replyId)
if !isGatewayConnected {
self.queuedReplies.append(event)
return .queue(replyId: replyId, actionId: actionId)
}
return .forward
}
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
let pending = self.queuedReplies
self.queuedReplies.removeAll()
return pending
}
func requeueFront(_ event: WatchQuickReplyEvent) {
self.queuedReplies.insert(event, at: 0)
}
var queuedCount: Int {
self.queuedReplies.count
}
}
@MainActor
final class WatchChatCoordinator {
final class WatchMessageOutbox {
enum Decision {
case dropMissingFields
case dropMissingTarget
case deduped(commandId: String)
case queue(commandId: String)
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 maxRecentCommandIds = 128
private static let persistedMetadataKey = "watch.message.outbox.metadata.v1"
private static let maxRecentMessageIDs = 128
private static let maxPromptRoutes = 128
private struct QueuedCommand: Codable, Equatable {
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 queuedCommands: [QueuedCommand] = []
private var recentCommandIds: [String] = []
private var seenCommandIds = Set<String>()
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,
isChatAvailable: Bool,
isAvailable: Bool,
gatewayStableID: String?) -> Decision
{
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if commandId.isEmpty || text.isEmpty {
if messageID.isEmpty || text.isEmpty {
return .dropMissingFields
}
if self.seenCommandIds.contains(commandId) {
return .deduped(commandId: commandId)
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty else { return .dropMissingTarget }
if self.seenMessageIDs.contains(messageID) {
return .deduped(messageID: messageID)
}
self.rememberRecentCommandId(commandId)
if !isChatAvailable {
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty else { return .dropMissingTarget }
self.queuedCommands.append(
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)))
self.rebuildSeenCommandIds()
self.persistQueue()
return .queue(commandId: commandId)
}
return .forward
// 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 nextQueuedCommand(isChatAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard isChatAvailable, !owner.isEmpty else { return nil }
return self.queuedCommands.first { $0.gatewayStableID == owner }?.event
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 removeQueuedCommand(commandId: String, gatewayStableID: String?) {
let commandId = commandId.trimmingCharacters(in: .whitespacesAndNewlines)
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 !commandId.isEmpty, !owner.isEmpty else { return }
guard let index = self.queuedCommands.firstIndex(where: {
$0.gatewayStableID == owner && $0.event.commandId == commandId
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.queuedCommands.remove(at: index)
self.rememberRecentCommandId(commandId)
self.queuedMessages.remove(at: index)
self.rememberRecentMessageID(messageID)
self.persistQueue()
}
func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) {
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty else { return }
if !commandId.isEmpty {
self.rememberRecentCommandId(commandId)
self.queuedCommands.removeAll { $0.event.commandId == commandId }
}
self.queuedCommands.insert(
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)),
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.rebuildSeenCommandIds()
self.rebuildSeenMessageIDs()
self.persistQueue()
}
var queuedCount: Int {
self.queuedCommands.count
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 })
}
var queuedCommandIds: [String] {
self.queuedCommands.map(\.event.commandId)
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([QueuedCommand].self, from: data)
let persisted = try? JSONDecoder().decode([QueuedMessage].self, from: data)
else {
return
}
var seen: [String] = []
var seenSet = Set<String>()
self.queuedCommands = persisted.compactMap { queued in
self.queuedMessages = persisted.compactMap { queued in
let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let commandId = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let messageID = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !owner.isEmpty, !commandId.isEmpty, !text.isEmpty, seenSet.insert(commandId).inserted else {
guard !owner.isEmpty, !messageID.isEmpty, !text.isEmpty, seenSet.insert(messageID).inserted else {
return nil
}
seen.append(commandId)
return QueuedCommand(gatewayStableID: owner, event: self.command(queued.event, taggedFor: owner))
return QueuedMessage(gatewayStableID: owner, event: self.message(queued.event, taggedFor: owner))
}
self.recentCommandIds = Array(seen.suffix(Self.maxRecentCommandIds))
self.rebuildSeenCommandIds()
if self.queuedCommands.count != persisted.count {
self.rebuildSeenMessageIDs()
if self.queuedMessages.count != persisted.count {
self.persistQueue()
}
}
private func rememberRecentCommandId(_ commandId: String) {
guard !commandId.isEmpty else { return }
self.recentCommandIds.removeAll { $0 == commandId }
self.recentCommandIds.append(commandId)
if self.recentCommandIds.count > Self.maxRecentCommandIds {
self.recentCommandIds.removeFirst(self.recentCommandIds.count - Self.maxRecentCommandIds)
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.rebuildSeenCommandIds()
self.rebuildSeenMessageIDs()
self.persistMetadata()
}
private func rebuildSeenCommandIds() {
var ids = Set(self.recentCommandIds)
ids.formUnion(self.queuedCommands.map(\.event.commandId))
self.seenCommandIds = ids
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.queuedCommands.isEmpty {
if self.queuedMessages.isEmpty {
self.defaults.removeObject(forKey: Self.persistedQueueKey)
return
}
guard let data = try? JSONEncoder().encode(queuedCommands) else { return }
guard let data = try? JSONEncoder().encode(self.queuedMessages) else { return }
self.defaults.set(data, forKey: Self.persistedQueueKey)
}
private func command(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
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)
}
}

View File

@@ -11,6 +11,7 @@ private struct PendingWatchPromptAction {
var actionId: String
var actionLabel: String?
var sessionKey: String?
var gatewayStableID: String?
}
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
@@ -63,7 +64,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
promptId: action.promptId,
actionId: action.actionId,
actionLabel: action.actionLabel,
sessionKey: action.sessionKey)
sessionKey: action.sessionKey,
gatewayStableID: action.gatewayStableID)
}
}
}
@@ -276,6 +278,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String
let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String
let gatewayStableID = userInfo[WatchPromptNotificationBridge.gatewayStableIDKey] as? String
switch response.actionIdentifier {
case WatchPromptNotificationBridge.actionPrimaryIdentifier:
@@ -287,7 +290,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey)
sessionKey: sessionKey,
gatewayStableID: gatewayStableID)
case WatchPromptNotificationBridge.actionSecondaryIdentifier:
let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -297,7 +301,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey)
sessionKey: sessionKey,
gatewayStableID: gatewayStableID)
default:
break
}
@@ -322,7 +327,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey)
sessionKey: sessionKey,
gatewayStableID: gatewayStableID)
}
private static func parseExecApprovalPrompt(
@@ -342,7 +348,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
promptId: action.promptId,
actionId: action.actionId,
actionLabel: action.actionLabel,
sessionKey: action.sessionKey)
sessionKey: action.sessionKey,
gatewayStableID: action.gatewayStableID)
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
}
@@ -407,6 +414,7 @@ enum WatchPromptNotificationBridge {
static let typeValue = "watch.prompt"
static let promptIDKey = "openclaw.watch.promptId"
static let sessionKeyKey = "openclaw.watch.sessionKey"
static let gatewayStableIDKey = "openclaw.watch.gatewayStableID"
static let actionPrimaryIDKey = "openclaw.watch.action.primary.id"
static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label"
static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id"
@@ -422,6 +430,7 @@ enum WatchPromptNotificationBridge {
static func scheduleMirroredWatchPromptNotificationIfNeeded(
invokeID: String,
params: OpenClawWatchNotifyParams,
gatewayStableID: String?,
sendResult: WatchNotificationSendResult) async
{
guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return }
@@ -461,6 +470,11 @@ enum WatchPromptNotificationBridge {
if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty {
userInfo[self.sessionKeyKey] = sessionKey
}
if let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
userInfo[self.gatewayStableIDKey] = gatewayStableID
}
for (index, action) in displayedActions.enumerated() {
userInfo[self.actionIDKey(index: index)] = action.id
userInfo[self.actionLabelKey(index: index)] = action.label
@@ -594,13 +608,15 @@ extension NodeAppModel {
promptId: String?,
actionId: String,
actionLabel: String?,
sessionKey: String?) async
sessionKey: String?,
gatewayStableID: String?) async
{
let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedActionID.isEmpty else { return }
let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedGatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
let event = WatchQuickReplyEvent(
@@ -609,6 +625,7 @@ extension NodeAppModel {
actionId: normalizedActionID,
actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil,
sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil,
gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil,
note: "source=ios.notification",
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
transport: "ios.notification")

View File

@@ -76,17 +76,23 @@ struct WatchMessagingStatus: Equatable {
var activationState: String
}
struct WatchQuickReplyEvent: Equatable {
struct WatchQuickReplyEvent: Codable, Equatable {
var replyId: String
var promptId: String
var actionId: String
var actionLabel: String?
var sessionKey: String?
var gatewayStableID: String?
var note: String?
var sentAtMs: Int?
var transport: String
}
enum WatchMessageKind: String, Codable, Equatable {
case chat
case quickReply
}
struct WatchExecApprovalResolveEvent: Equatable {
var replyId: String
var approvalId: String
@@ -115,6 +121,7 @@ struct WatchAppCommandEvent: Codable, Equatable {
var text: String?
var sentAtMs: Int?
var transport: String
var messageKind: WatchMessageKind? = nil
}
struct WatchNotificationSendResult: Equatable {
@@ -134,7 +141,8 @@ protocol WatchMessagingServicing: AnyObject, Sendable {
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
params: OpenClawWatchNotifyParams,
gatewayStableID: String?) async throws -> WatchNotificationSendResult
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
func sendExecApprovalResolved(

View File

@@ -15,7 +15,8 @@ enum WatchMessagingPayloadCodec {
static func encodeNotificationPayload(
id: String,
params: OpenClawWatchNotifyParams) -> [String: Any]
params: OpenClawWatchNotifyParams,
gatewayStableID: String?) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.notify.rawValue,
@@ -31,6 +32,9 @@ enum WatchMessagingPayloadCodec {
if let sessionKey = nonEmpty(params.sessionKey) {
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = nonEmpty(gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let kind = nonEmpty(params.kind) {
payload["kind"] = kind
}
@@ -232,6 +236,7 @@ enum WatchMessagingPayloadCodec {
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let note = self.nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
@@ -241,6 +246,7 @@ enum WatchMessagingPayloadCodec {
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey,
gatewayStableID: gatewayStableID,
note: note,
sentAtMs: sentAtMs,
transport: transport)

View File

@@ -117,9 +117,13 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
params: OpenClawWatchNotifyParams,
gatewayStableID: String?) async throws -> WatchNotificationSendResult
{
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params)
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(
id: id,
params: params,
gatewayStableID: gatewayStableID)
return try await self.transport.sendPayload(payload)
}

View File

@@ -100,7 +100,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
queuedForDelivery: false,
transport: "sendMessage")
var sendError: Error?
var lastSent: (id: String, params: OpenClawWatchNotifyParams)?
var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)?
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
@@ -144,8 +144,12 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
self.appCommandHandler = handler
}
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
self.lastSent = (id: id, params: params)
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams,
gatewayStableID: String?) async throws -> WatchNotificationSendResult
{
self.lastSent = (id: id, params: params, gatewayStableID: gatewayStableID)
if let sendError {
throw sendError
}
@@ -1072,7 +1076,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
let coordinator = WatchMessageOutbox(defaults: defaults)
let first = WatchAppCommandEvent(
commandId: "watch-send-chat-gateway-a-1",
command: .sendChat,
@@ -1090,32 +1094,32 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
sentAtMs: 132,
transport: "sendMessage")
if case .queue = coordinator.ingest(first, isChatAvailable: false, gatewayStableID: "gateway-a") {
if case .queue = coordinator.ingest(first, isAvailable: false, gatewayStableID: "gateway-a") {
} else {
Issue.record("expected first gateway A command to queue")
}
if case .queue = coordinator.ingest(second, isChatAvailable: false, gatewayStableID: "gateway-a") {
if case .queue = coordinator.ingest(second, isAvailable: false, gatewayStableID: "gateway-a") {
} else {
Issue.record("expected second gateway A command to queue")
}
#expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil)
coordinator.removeQueuedCommand(
commandId: "watch-send-chat-gateway-a-1",
#expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil)
coordinator.removeQueuedMessage(
messageID: "watch-send-chat-gateway-a-1",
gatewayStableID: "gateway-b")
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-gateway-a-1")
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-gateway-a-1")
coordinator.removeQueuedCommand(
commandId: "watch-send-chat-gateway-a-1",
coordinator.removeQueuedMessage(
messageID: "watch-send-chat-gateway-a-1",
gatewayStableID: "gateway-a")
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-gateway-a-2")
}
@@ -1127,7 +1131,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
let coordinator = WatchMessageOutbox(defaults: defaults)
let event = WatchAppCommandEvent(
commandId: "watch-send-chat-retry-gateway-a",
command: .sendChat,
@@ -1139,12 +1143,50 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
coordinator.requeueFront(event, gatewayStableID: event.gatewayStableID)
#expect(coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-b") == nil)
#expect(coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil)
#expect(
coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")?.commandId ==
"watch-send-chat-retry-gateway-a")
}
@Test @MainActor func watchMessageOutboxPrioritizesRepliesOverQueuedChat() throws {
let suiteName = "watch-message-priority-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let outbox = WatchMessageOutbox(defaults: defaults)
let chat = WatchAppCommandEvent(
commandId: "queued-chat",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "Chat first",
sentAtMs: 1,
transport: "transferUserInfo")
let reply = WatchAppCommandEvent(
commandId: "queued-reply",
command: .sendChat,
sessionKey: nil,
gatewayStableID: "gateway-a",
text: "Reply second",
sentAtMs: 2,
transport: "transferUserInfo",
messageKind: .quickReply)
_ = outbox.ingest(chat, isAvailable: false, gatewayStableID: "gateway-a")
_ = outbox.ingest(reply, isAvailable: false, gatewayStableID: "gateway-a")
#expect(outbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") == reply)
}
@Test func watchMessageOutboxDiscardsPermanentGatewayFailures() {
#expect(NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "INVALID_REQUEST"))
#expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage(
code: "INVALID_REQUEST",
message: "Session changed while starting work. Retry."))
#expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "UNAVAILABLE"))
}
@Test @MainActor func watchChatRestoreBackfillsGatewayOwnerIntoLegacyQueuedEvent() throws {
let suiteName = "watch-chat-restore-legacy-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
@@ -1153,26 +1195,26 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
defaults.removePersistentDomain(forName: suiteName)
}
let legacyQueueJSON = """
[
{
"gatewayStableID": "gateway-a",
"event": {
"commandId": "watch-send-chat-legacy",
"command": "send-chat",
"sessionKey": "main",
"text": "Legacy queued text",
"sentAtMs": 134,
"transport": "transferUserInfo"
}
}
]
"""
[
{
"gatewayStableID": "gateway-a",
"event": {
"commandId": "watch-send-chat-legacy",
"command": "send-chat",
"sessionKey": "main",
"text": "Legacy queued text",
"sentAtMs": 134,
"transport": "transferUserInfo"
}
}
]
"""
defaults.set(
Data(legacyQueueJSON.utf8),
forKey: "watch.chat.command.queue.v1")
let coordinator = WatchChatCoordinator(defaults: defaults)
let restored = coordinator.nextQueuedCommand(isChatAvailable: true, gatewayStableID: "gateway-a")
let coordinator = WatchMessageOutbox(defaults: defaults)
let restored = coordinator.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")
#expect(restored?.commandId == "watch-send-chat-legacy")
#expect(restored?.gatewayStableID == "gateway-a")
@@ -1186,7 +1228,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
let coordinator = WatchMessageOutbox(defaults: defaults)
for index in 0..<140 {
let event = WatchAppCommandEvent(
commandId: "watch-forward-\(index)",
@@ -1198,9 +1240,12 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
transport: "sendMessage")
if case .forward = coordinator.ingest(
event,
isChatAvailable: true,
isAvailable: true,
gatewayStableID: "gateway-a")
{
coordinator.removeQueuedMessage(
messageID: event.commandId,
gatewayStableID: "gateway-a")
} else {
Issue.record("expected forwarded command \(index)")
}
@@ -1216,7 +1261,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
transport: "sendMessage")
if case .forward = coordinator.ingest(
oldestEvent,
isChatAvailable: true,
isAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
@@ -1233,7 +1278,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
transport: "sendMessage")
if case .deduped = coordinator.ingest(
recentEvent,
isChatAvailable: true,
isAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
@@ -1249,7 +1294,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
defaults.removePersistentDomain(forName: suiteName)
}
let coordinator = WatchChatCoordinator(defaults: defaults)
let coordinator = WatchMessageOutbox(defaults: defaults)
for index in 0..<140 {
let event = WatchAppCommandEvent(
commandId: "watch-queued-\(index)",
@@ -1261,7 +1306,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
transport: "transferUserInfo")
if case .queue = coordinator.ingest(
event,
isChatAvailable: false,
isAvailable: false,
gatewayStableID: "gateway-a")
{
} else {
@@ -1269,8 +1314,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
}
}
coordinator.removeQueuedCommand(
commandId: "watch-queued-0",
coordinator.removeQueuedMessage(
messageID: "watch-queued-0",
gatewayStableID: "gateway-a")
let duplicateDeliveredEvent = WatchAppCommandEvent(
@@ -1283,7 +1328,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
transport: "transferUserInfo")
if case .deduped = coordinator.ingest(
duplicateDeliveredEvent,
isChatAvailable: true,
isAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
@@ -1792,6 +1837,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
queuedForDelivery: true,
transport: "transferUserInfo")
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setConnectedGatewayID("gateway-watch-notify")
let params = OpenClawWatchNotifyParams(
title: "OpenClaw",
body: "Meeting with Peter is at 4pm",
@@ -1808,6 +1854,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(watchService.lastSent?.params.title == "OpenClaw")
#expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm")
#expect(watchService.lastSent?.params.priority == .timeSensitive)
#expect(watchService.lastSent?.gatewayStableID == "gateway-watch-notify")
let payloadData = try #require(res.payloadJSON?.data(using: .utf8))
let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData)
@@ -1928,8 +1975,11 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
}
@Test @MainActor func watchReplyQueuesWhenGatewayOffline() async {
NodeAppModel._test_resetPersistedWatchReplyQueueState()
defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setConnectedGatewayID("gateway-watch-reply")
watchService.emitReply(
WatchQuickReplyEvent(
replyId: "reply-offline-1",
@@ -1937,6 +1987,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
actionId: "approve",
actionLabel: "Approve",
sessionKey: "ios",
gatewayStableID: "gateway-watch-reply",
note: nil,
sentAtMs: 1234,
transport: "transferUserInfo"))
@@ -1944,6 +1995,162 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(appModel._test_queuedWatchReplyCount() == 1)
}
@Test @MainActor func watchMessageOutboxRestoresQueuedReplyAfterRestart() throws {
let suiteName = "watch-reply-queue-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let event = WatchAppCommandEvent(
commandId: "reply-restore-1",
command: .sendChat,
sessionKey: "ios",
gatewayStableID: "gateway-a",
text: "Watch reply: Approve",
sentAtMs: 1235,
transport: "transferUserInfo",
messageKind: .quickReply)
let firstOutbox = WatchMessageOutbox(defaults: defaults)
if case .queue = firstOutbox.ingest(event, isAvailable: false, gatewayStableID: "gateway-a") {
} else {
Issue.record("expected watch reply to queue")
}
let secondOutbox = WatchMessageOutbox(defaults: defaults)
#expect(secondOutbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-b") == nil)
let restored = secondOutbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a")
#expect(restored == event)
#expect(secondOutbox.queuedCount(kind: .quickReply) == 1)
secondOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a")
#expect(secondOutbox.queuedCount() == 0)
}
@Test @MainActor func watchMessageOutboxRestoresDeliveryTombstonesAndPromptRoutes() throws {
let suiteName = "watch-message-metadata-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let event = WatchAppCommandEvent(
commandId: "delivered-reply",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "Delivered reply",
sentAtMs: 1,
transport: "sendMessage",
messageKind: .quickReply)
let firstOutbox = WatchMessageOutbox(defaults: defaults)
firstOutbox.recordPromptRoute(promptID: "prompt-a", gatewayStableID: "gateway-a")
_ = firstOutbox.ingest(event, isAvailable: true, gatewayStableID: "gateway-a")
firstOutbox.removeQueuedMessage(messageID: event.commandId, gatewayStableID: "gateway-a")
for index in 0..<140 {
let pending = WatchAppCommandEvent(
commandId: "pending-\(index)",
command: .sendChat,
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "Pending \(index)",
sentAtMs: index + 2,
transport: "transferUserInfo")
_ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a")
}
let restoredOutbox = WatchMessageOutbox(defaults: defaults)
#expect(restoredOutbox.gatewayStableID(forPromptID: "prompt-a") == "gateway-a")
if case .deduped = restoredOutbox.ingest(
event,
isAvailable: true,
gatewayStableID: "gateway-a")
{
} else {
Issue.record("expected delivered reply to remain deduped after restart")
}
}
@Test @MainActor func watchReplyDropsStaleGatewayTarget() async {
NodeAppModel._test_resetPersistedWatchReplyQueueState()
defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel._test_setConnectedGatewayID("gateway-current")
watchService.emitReply(
WatchQuickReplyEvent(
replyId: "reply-stale-gateway",
promptId: "prompt-stale",
actionId: "approve",
actionLabel: "Approve",
sessionKey: "ios",
gatewayStableID: "gateway-old",
note: nil,
sentAtMs: 1236,
transport: "transferUserInfo"))
await Task.yield()
#expect(appModel._test_queuedWatchReplyCount() == 0)
#expect(appModel.openChatRequestID == 0)
}
@Test @MainActor func watchReplyUsesIdempotentChatOutbox() async {
NodeAppModel._test_resetPersistedWatchReplyQueueState()
defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel.enterAppleReviewDemoMode()
appModel._test_recordWatchPromptRoute(
promptID: "prompt-idempotent",
gatewayStableID: AppleReviewDemoMode.gatewayID)
let initialOpenChatRequestID = appModel.openChatRequestID
let event = WatchQuickReplyEvent(
replyId: "reply-idempotent",
promptId: "prompt-idempotent",
actionId: "approve",
actionLabel: "Approve",
sessionKey: "main",
gatewayStableID: nil,
note: nil,
sentAtMs: 1237,
transport: "sendMessage")
watchService.emitReply(event)
await Task.yield()
watchService.emitReply(event)
await Task.yield()
#expect(appModel.openChatRequestID == initialOpenChatRequestID + 1)
#expect(appModel._test_queuedWatchReplyCount() == 0)
}
@Test @MainActor func watchReplyBindsLegacyPromptToCurrentGateway() async {
NodeAppModel._test_resetPersistedWatchReplyQueueState()
defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() }
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel.enterAppleReviewDemoMode()
let initialOpenChatRequestID = appModel.openChatRequestID
let event = WatchQuickReplyEvent(
replyId: "reply-legacy-prompt",
promptId: "prompt-from-previous-release",
actionId: "approve",
actionLabel: "Approve",
sessionKey: "main",
gatewayStableID: nil,
note: nil,
sentAtMs: 1238,
transport: "sendMessage")
watchService.emitReply(event)
await Task.yield()
watchService.emitReply(event)
await Task.yield()
#expect(appModel.openChatRequestID == initialOpenChatRequestID + 1)
#expect(appModel._test_queuedWatchReplyCount() == 0)
}
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async throws {
let appModel = NodeAppModel()
let url = try #require(URL(string: "openclaw://agent?message=hello"))
@@ -1962,6 +2169,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
@Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async {
let appModel = NodeAppModel()
appModel._test_setGatewayConnected(true)
appModel._test_setAgentRequestHandler { _ in }
let url = makeAgentDeepLinkURL(message: "hello from deep link")
await appModel.handleDeepLink(url: url)
@@ -2017,6 +2225,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
@Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async {
let appModel = NodeAppModel()
appModel._test_setGatewayConnected(true)
appModel._test_setAgentRequestHandler { _ in }
let key = NodeAppModel._test_currentDeepLinkKey()
let url = makeAgentDeepLinkURL(message: "trusted request", key: key)

View File

@@ -7,6 +7,7 @@ struct WatchReplyDraft {
var actionId: String
var actionLabel: String?
var sessionKey: String?
var gatewayStableID: String?
var note: String?
var sentAtMs: Int
}
@@ -115,6 +116,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
{
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = draft.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
payload["gatewayStableID"] = gatewayStableID
}
if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
payload["note"] = note
}
@@ -250,6 +256,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let kind = (payload["kind"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let details = (payload["details"] as? String)?
@@ -266,6 +274,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
sentAtMs: sentAtMs,
promptId: promptId,
sessionKey: sessionKey,
gatewayStableID: gatewayStableID,
kind: kind,
details: details,
expiresAtMs: expiresAtMs,

View File

@@ -154,6 +154,7 @@ struct WatchNotifyMessage {
var sentAtMs: Int?
var promptId: String?
var sessionKey: String?
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int?
@@ -184,6 +185,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var lastDeliveryKey: String?
var promptId: String?
var sessionKey: String?
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int?
@@ -213,6 +215,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var updatedAt: Date?
var promptId: String?
var sessionKey: String?
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int?
@@ -361,6 +364,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.updatedAt = Date()
self.promptId = message.promptId
self.sessionKey = message.sessionKey
self.gatewayStableID = message.gatewayStableID
self.kind = message.kind
self.details = message.details
self.expiresAtMs = message.expiresAtMs
@@ -676,6 +680,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.lastDeliveryKey = state.lastDeliveryKey
self.promptId = state.promptId
self.sessionKey = state.sessionKey
self.gatewayStableID = state.gatewayStableID
self.kind = state.kind
self.details = state.details
self.expiresAtMs = state.expiresAtMs
@@ -704,6 +709,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
lastDeliveryKey: lastDeliveryKey,
promptId: promptId,
sessionKey: sessionKey,
gatewayStableID: gatewayStableID,
kind: kind,
details: details,
expiresAtMs: expiresAtMs,
@@ -761,6 +767,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
actionId: action.id,
actionLabel: action.label,
sessionKey: self.sessionKey,
gatewayStableID: self.gatewayStableID,
note: nil,
sentAtMs: Self.nowMs())
}