mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 18:08:05 -06:00
923e972564
* fix(apple): gate gateway RPC polling on the hello method catalog Released 2026.7.x gateways authorize before method dispatch and reject unknown methods with INVALID_REQUEST / "missing scope: operator.admin", which never names the method, so the shared Swift kit's error-text matchers could not detect an unsupported gateway: queued sends wedged in a delay-free sessions.branches.list reconcile loop, question.list burned its retry budget per health event, and progressCard.get fired a rejected fetch per event. Port the Android fix pattern (#126540): generalize the progressCard-only transport seam into tri-state gatewayAdvertisesMethod(_:), make hello catalog parsing distinguish absent (nil) from empty, route branch listing through a catalog-checking dispatch point with a typed BranchListingUnadvertisedError, tighten error-text matching to the modern "unknown method:" shape, and skip question.list/progressCard.get when unadvertised. Regression tests encode the exact 2026.7.1-2 wire shape and fail pre-fix (A/B verified). * fix(apple): keep explicit unsupported branch-listing replies releasing sends ClawSweeper P1: the tightened matcher dropped the shipped acceptance of explicit unsupported/unimplemented GatewayResponseError replies that name sessions.branches.list, which would wedge queued sends on a pre-catalog gateway emitting that shape. Collapse the matcher onto the bridged localizedDescription (errorDescription always prefixes the method name), preserving both legacy qualifier shapes while still rejecting bare missing-scope denials and the old false-positive-prone INVALID_REQUEST arm; add the releasing-send regression test.
214 lines
8.0 KiB
Swift
214 lines
8.0 KiB
Swift
import Foundation
|
|
import OpenClawProtocol
|
|
|
|
extension OpenClawChatViewModel {
|
|
func handleProgressCardChanged(_ event: ProgressCardChangedEvent) {
|
|
guard self.matchesCurrentSessionKey(
|
|
incoming: event.sessionkey,
|
|
current: self.sessionKey)
|
|
else { return }
|
|
|
|
if event.revision.value is NSNull {
|
|
self.clearProgressCard()
|
|
return
|
|
}
|
|
if event.revision.value as? Int == self.progressCard?.revision {
|
|
return
|
|
}
|
|
self.scheduleProgressCardFetch()
|
|
}
|
|
|
|
func scheduleProgressCardFetch(for session: SessionSnapshot? = nil) {
|
|
let session = session ?? self.currentSessionSnapshot()
|
|
guard self.isCurrentSession(session) else { return }
|
|
self.lastIssuedProgressCardRequestID &+= 1
|
|
let requestID = self.lastIssuedProgressCardRequestID
|
|
let generation = self.progressCardGeneration
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
let storeAvailable = await self.transport.gatewayAdvertisesMethod("progressCard.get")
|
|
self.progressCardStoreAvailable = storeAvailable
|
|
// Gateways without the durable store reject the fetch outright
|
|
// (2026.7.x: "missing scope: operator.admin"); the legacy
|
|
// stream:"plan" fallback owns the card there.
|
|
guard storeAvailable != false else { return }
|
|
await self.fetchProgressCard(
|
|
for: session,
|
|
generation: generation,
|
|
requestID: requestID)
|
|
}
|
|
}
|
|
|
|
func clearProgressCard() {
|
|
self.progressCardGeneration &+= 1
|
|
self.applyProgressCard(nil)
|
|
}
|
|
|
|
private func fetchProgressCard(
|
|
for session: SessionSnapshot,
|
|
generation: UInt64,
|
|
requestID: UInt64) async
|
|
{
|
|
do {
|
|
let card = try await self.transport.fetchProgressCard(sessionKey: session.key)
|
|
guard self.isCurrentProgressCardRequest(
|
|
session: session,
|
|
generation: generation,
|
|
requestID: requestID)
|
|
else { return }
|
|
self.applyProgressCard(card)
|
|
} catch {
|
|
guard self.isCurrentProgressCardRequest(
|
|
session: session,
|
|
generation: generation,
|
|
requestID: requestID)
|
|
else { return }
|
|
// Keep the last rendered card on transient failure: the durable
|
|
// store clears only via a successful null fetch or a null-revision
|
|
// poke, never via a failed refresh.
|
|
self.logDiagnostic(
|
|
"chat.ui progress card fetch failed sessionKey=\(session.key) "
|
|
+ "error=\(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func isCurrentProgressCardRequest(
|
|
session: SessionSnapshot,
|
|
generation: UInt64,
|
|
requestID: UInt64) -> Bool
|
|
{
|
|
self.progressCardGeneration == generation &&
|
|
self.lastIssuedProgressCardRequestID == requestID &&
|
|
self.isCurrentSession(session)
|
|
}
|
|
|
|
func applyProgressCard(_ card: ProgressCard?) {
|
|
let normalized = Self.normalizedProgressCard(card)
|
|
let previousPresentation = Self.progressCardPresentation(self.progressCard)
|
|
let presentation = Self.progressCardPresentation(normalized)
|
|
let presentationChanged = previousPresentation != presentation
|
|
guard presentationChanged ||
|
|
self.progressCard?.sessionkey != normalized?.sessionkey ||
|
|
self.progressCard?.revision != normalized?.revision
|
|
else { return }
|
|
self.progressCard = normalized
|
|
if presentationChanged {
|
|
self.markTimelineChanged()
|
|
}
|
|
}
|
|
|
|
private static func normalizedProgressCard(_ card: ProgressCard?) -> ProgressCard? {
|
|
guard let card else { return nil }
|
|
let markdown = card.markdown?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return markdown?.isEmpty == false || card.steps?.isEmpty == false ? card : nil
|
|
}
|
|
|
|
private static func progressCardPresentation(_ card: ProgressCard?) -> [String]? {
|
|
guard let card else { return nil }
|
|
return [card.markdown == nil ? "0" : "1", card.markdown ?? ""] +
|
|
(card.steps ?? []).flatMap { [$0.step, $0.status.rawValue] }
|
|
}
|
|
|
|
static func parseLegacyProgressCardSteps(_ value: AnyCodable?) -> [ProgressCardStep] {
|
|
guard let value else { return [] }
|
|
let rawItems: [Any]
|
|
switch value.value {
|
|
case let items as [AnyCodable]:
|
|
rawItems = items.map(\.value)
|
|
case let items as [Any]:
|
|
rawItems = items
|
|
case let items as NSArray:
|
|
rawItems = items.map(\.self)
|
|
default:
|
|
return []
|
|
}
|
|
var hasInProgressStep = false
|
|
return rawItems.compactMap { rawItem in
|
|
guard let step = Self.parseLegacyProgressCardStep(rawItem) else { return nil }
|
|
if case .inProgress = step.status {
|
|
guard !hasInProgressStep else { return nil }
|
|
hasInProgressStep = true
|
|
}
|
|
return step
|
|
}
|
|
}
|
|
|
|
private static func parseLegacyProgressCardStep(_ rawValue: Any) -> ProgressCardStep? {
|
|
let value = (rawValue as? AnyCodable)?.value ?? rawValue
|
|
if let legacyStep = value as? String {
|
|
return self.makeLegacyProgressCardStep(text: legacyStep, status: .pending)
|
|
}
|
|
|
|
let fields: [String: Any]
|
|
switch value {
|
|
case let dictionary as [String: AnyCodable]:
|
|
fields = dictionary.mapValues(\.value)
|
|
case let dictionary as [String: String]:
|
|
fields = dictionary
|
|
case let dictionary as [String: Any]:
|
|
fields = dictionary
|
|
case let dictionary as NSDictionary:
|
|
fields = dictionary.reduce(into: [:]) { result, entry in
|
|
guard let key = entry.key as? String else { return }
|
|
result[key] = (entry.value as? AnyCodable)?.value ?? entry.value
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
guard let text = fields["step"] as? String,
|
|
let rawStatus = fields["status"] as? String,
|
|
let status = ProgressCardStepStatus(rawValue: rawStatus)
|
|
else {
|
|
return nil
|
|
}
|
|
return self.makeLegacyProgressCardStep(text: text, status: status)
|
|
}
|
|
|
|
private static func makeLegacyProgressCardStep(
|
|
text: String,
|
|
status: ProgressCardStepStatus) -> ProgressCardStep?
|
|
{
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return ProgressCardStep(step: trimmed, status: status)
|
|
}
|
|
}
|
|
|
|
/// Session-run activity indicator for runs without a chat snapshot.
|
|
extension OpenClawChatViewModel {
|
|
func updateActiveSessionRunWithoutChatSnapshot(_ active: Bool) {
|
|
guard self.hasActiveSessionRunWithoutChatSnapshot != active else { return }
|
|
self.hasActiveSessionRunWithoutChatSnapshot = active
|
|
if active {
|
|
self.armActiveSessionRunIndicatorTimeout()
|
|
} else {
|
|
self.activeSessionRunIndicatorTimeoutTask?.cancel()
|
|
self.activeSessionRunIndicatorTimeoutTask = nil
|
|
}
|
|
self.markTimelineChanged()
|
|
}
|
|
|
|
private func armActiveSessionRunIndicatorTimeout() {
|
|
self.activeSessionRunIndicatorTimeoutTask?.cancel()
|
|
let timeoutMs = self.pendingRunWaitTimeoutMs
|
|
self.activeSessionRunIndicatorTimeoutTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
|
|
} catch {
|
|
return
|
|
}
|
|
await MainActor.run {
|
|
self?.updateActiveSessionRunWithoutChatSnapshot(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clearActiveSessionRunIndicatorIfLatestUserAnswered() {
|
|
guard self.hasActiveSessionRunWithoutChatSnapshot,
|
|
!Self.hasUnansweredLatestUser(in: self.messages)
|
|
else { return }
|
|
self.updateActiveSessionRunWithoutChatSnapshot(false)
|
|
}
|
|
}
|