Mac: use typed GatewayFrame + forward-compatible Swift generator
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ClawdisProtocol
|
||||||
|
|
||||||
struct ControlHeartbeatEvent: Codable {
|
struct ControlHeartbeatEvent: Codable {
|
||||||
let ts: Double
|
let ts: Double
|
||||||
@@ -132,7 +133,7 @@ final class ControlChannel: ObservableObject {
|
|||||||
forName: .gatewayEvent,
|
forName: .gatewayEvent,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main)
|
queue: .main)
|
||||||
{ [weak self] @MainActor note in
|
{ [weak self] note in
|
||||||
guard let self,
|
guard let self,
|
||||||
let obj = note.userInfo as? [String: Any],
|
let obj = note.userInfo as? [String: Any],
|
||||||
let event = obj["event"] as? String else { return }
|
let event = obj["event"] as? String else { return }
|
||||||
@@ -146,18 +147,20 @@ final class ControlChannel: ObservableObject {
|
|||||||
let dataDict = payload["data"] as? [String: Any]
|
let dataDict = payload["data"] as? [String: Any]
|
||||||
{
|
{
|
||||||
let wrapped = dataDict.mapValues { AnyCodable($0) }
|
let wrapped = dataDict.mapValues { AnyCodable($0) }
|
||||||
AgentEventStore.shared.append(ControlAgentEvent(
|
Task { @MainActor in
|
||||||
runId: runId,
|
AgentEventStore.shared.append(ControlAgentEvent(
|
||||||
seq: seq,
|
runId: runId,
|
||||||
stream: stream,
|
seq: seq,
|
||||||
ts: ts,
|
stream: stream,
|
||||||
data: wrapped))
|
ts: ts,
|
||||||
|
data: wrapped))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "presence":
|
case "presence":
|
||||||
// InstancesStore listens separately via notification
|
// InstancesStore listens separately via notification
|
||||||
break
|
break
|
||||||
case "shutdown":
|
case "shutdown":
|
||||||
self.state = .degraded("gateway shutdown")
|
Task { @MainActor in self.state = .degraded("gateway shutdown") }
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -166,8 +169,8 @@ final class ControlChannel: ObservableObject {
|
|||||||
forName: .gatewaySnapshot,
|
forName: .gatewaySnapshot,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main)
|
queue: .main)
|
||||||
{ [weak self] @MainActor _ in
|
{ [weak self] _ in
|
||||||
self?.state = .connected
|
Task { @MainActor [weak self] in self?.state = .connected }
|
||||||
}
|
}
|
||||||
self.eventTokens = [ev, tick]
|
self.eventTokens = [ev, tick]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import ClawdisProtocol
|
||||||
|
|
||||||
struct GatewayEvent: Codable {
|
struct GatewayEvent: Codable {
|
||||||
let type: String
|
let type: String
|
||||||
@@ -17,7 +18,7 @@ extension Notification.Name {
|
|||||||
private actor GatewayChannelActor {
|
private actor GatewayChannelActor {
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
|
||||||
private var task: URLSessionWebSocketTask?
|
private var task: URLSessionWebSocketTask?
|
||||||
private var pending: [String: CheckedContinuation<Data, Error>] = [:]
|
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
|
||||||
private var connected = false
|
private var connected = false
|
||||||
private var url: URL
|
private var url: URL
|
||||||
private var token: String?
|
private var token: String?
|
||||||
@@ -25,6 +26,8 @@ private actor GatewayChannelActor {
|
|||||||
private var backoffMs: Double = 500
|
private var backoffMs: Double = 500
|
||||||
private var shouldReconnect = true
|
private var shouldReconnect = true
|
||||||
private var lastSeq: Int?
|
private var lastSeq: Int?
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
init(url: URL, token: String?) {
|
init(url: URL, token: String?) {
|
||||||
self.url = url
|
self.url = url
|
||||||
@@ -90,8 +93,10 @@ private actor GatewayChannelActor {
|
|||||||
case let .failure(err):
|
case let .failure(err):
|
||||||
Task { await self.handleReceiveFailure(err) }
|
Task { await self.handleReceiveFailure(err) }
|
||||||
case let .success(msg):
|
case let .success(msg):
|
||||||
Task { await self.handle(msg) }
|
Task {
|
||||||
self.listen()
|
await self.handle(msg)
|
||||||
|
await self.listen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,26 +114,28 @@ private actor GatewayChannelActor {
|
|||||||
@unknown default: nil
|
@unknown default: nil
|
||||||
}
|
}
|
||||||
guard let data else { return }
|
guard let data else { return }
|
||||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||||
let type = obj["type"] as? String else { return }
|
self.logger.error("gateway decode failed")
|
||||||
switch type {
|
return
|
||||||
case "res":
|
}
|
||||||
if let id = obj["id"] as? String, let waiter = pending.removeValue(forKey: id) {
|
switch frame {
|
||||||
waiter.resume(returning: data)
|
case let .res(res):
|
||||||
|
if let id = res.id, let waiter = pending.removeValue(forKey: id) {
|
||||||
|
waiter.resume(returning: .res(res))
|
||||||
}
|
}
|
||||||
case "event":
|
case let .event(evt):
|
||||||
if let seq = obj["seq"] as? Int {
|
if let seq = evt.seq {
|
||||||
if let last = lastSeq, seq > last + 1 {
|
if let last = lastSeq, seq > last + 1 {
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: .gatewaySeqGap,
|
name: .gatewaySeqGap,
|
||||||
object: nil,
|
object: frame,
|
||||||
userInfo: ["expected": last + 1, "received": seq])
|
userInfo: ["expected": last + 1, "received": seq])
|
||||||
}
|
}
|
||||||
self.lastSeq = seq
|
self.lastSeq = seq
|
||||||
}
|
}
|
||||||
NotificationCenter.default.post(name: .gatewayEvent, object: nil, userInfo: obj)
|
NotificationCenter.default.post(name: .gatewayEvent, object: frame)
|
||||||
case "hello-ok":
|
case .helloOk:
|
||||||
NotificationCenter.default.post(name: .gatewaySnapshot, object: nil, userInfo: obj)
|
NotificationCenter.default.post(name: .gatewaySnapshot, object: frame)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -160,7 +167,7 @@ private actor GatewayChannelActor {
|
|||||||
"params": paramsObject,
|
"params": paramsObject,
|
||||||
]
|
]
|
||||||
let data = try JSONSerialization.data(withJSONObject: frame)
|
let data = try JSONSerialization.data(withJSONObject: frame)
|
||||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||||
self.pending[id] = cont
|
self.pending[id] = cont
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -171,7 +178,18 @@ private actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response
|
guard case let .res(res) = response else {
|
||||||
|
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
||||||
|
}
|
||||||
|
if res.ok == false {
|
||||||
|
let msg = (res.error?.message) ?? "gateway error"
|
||||||
|
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||||
|
}
|
||||||
|
if let payload = res.payload?.value {
|
||||||
|
let payloadData = try JSONSerialization.data(withJSONObject: payload)
|
||||||
|
return payloadData
|
||||||
|
}
|
||||||
|
return Data()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import ClawdisProtocol
|
||||||
|
|
||||||
struct InstanceInfo: Identifiable, Codable {
|
struct InstanceInfo: Identifiable, Codable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -65,12 +66,18 @@ final class InstancesStore: ObservableObject {
|
|||||||
forName: .gatewayEvent,
|
forName: .gatewayEvent,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main)
|
queue: .main)
|
||||||
{ [weak self] @MainActor note in
|
{ [weak self] note in
|
||||||
guard let self,
|
guard let self,
|
||||||
let obj = note.userInfo as? [String: Any],
|
let frame = note.object as? GatewayFrame else { return }
|
||||||
let event = obj["event"] as? String else { return }
|
switch frame {
|
||||||
if event == "presence", let payload = obj["payload"] as? [String: Any] {
|
case let .event(evt) where evt.event == "presence":
|
||||||
self.handlePresencePayload(payload)
|
if let payload = evt.payload?.value as? [String: Any],
|
||||||
|
let presence = payload["presence"],
|
||||||
|
let presenceData = try? JSONSerialization.data(withJSONObject: presence) {
|
||||||
|
Task { @MainActor [weak self] in self?.decodeAndApplyPresenceData(presenceData) }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let gap = NotificationCenter.default.addObserver(
|
let gap = NotificationCenter.default.addObserver(
|
||||||
@@ -85,12 +92,18 @@ final class InstancesStore: ObservableObject {
|
|||||||
forName: .gatewaySnapshot,
|
forName: .gatewaySnapshot,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main)
|
queue: .main)
|
||||||
{ [weak self] @MainActor note in
|
{ [weak self] note in
|
||||||
guard let self,
|
guard let self,
|
||||||
let obj = note.userInfo as? [String: Any],
|
let frame = note.object as? GatewayFrame else { return }
|
||||||
let snapshot = obj["snapshot"] as? [String: Any],
|
switch frame {
|
||||||
let presence = snapshot["presence"] else { return }
|
case let .helloOk(hello):
|
||||||
self.decodeAndApplyPresence(presence: presence)
|
let presence = hello.snapshot.presence
|
||||||
|
if let data = try? JSONEncoder().encode(presence) {
|
||||||
|
Task { @MainActor [weak self] in self?.decodeAndApplyPresenceData(data) }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.observers = [ev, snap, gap]
|
self.observers = [ev, snap, gap]
|
||||||
}
|
}
|
||||||
@@ -257,14 +270,7 @@ final class InstancesStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePresencePayload(_ payload: [String: Any]) {
|
private func decodeAndApplyPresenceData(_ data: Data) {
|
||||||
if let presence = payload["presence"] {
|
|
||||||
self.decodeAndApplyPresence(presence: presence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func decodeAndApplyPresence(presence: Any) {
|
|
||||||
guard let data = try? JSONSerialization.data(withJSONObject: presence) else { return }
|
|
||||||
do {
|
do {
|
||||||
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
||||||
let withIDs = decoded.map { entry -> InstanceInfo in
|
let withIDs = decoded.map { entry -> InstanceInfo in
|
||||||
@@ -285,6 +291,7 @@ final class InstancesStore: ObservableObject {
|
|||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ public enum GatewayFrame: Codable {
|
|||||||
case req(RequestFrame)
|
case req(RequestFrame)
|
||||||
case res(ResponseFrame)
|
case res(ResponseFrame)
|
||||||
case event(EventFrame)
|
case event(EventFrame)
|
||||||
|
case unknown(type: String, raw: [String: AnyCodable])
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
@@ -362,7 +363,7 @@ public enum GatewayFrame: Codable {
|
|||||||
case "event":
|
case "event":
|
||||||
self = .event(try decodePayload(EventFrame.self, from: raw))
|
self = .event(try decodePayload(EventFrame.self, from: raw))
|
||||||
default:
|
default:
|
||||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "unknown type (type)")
|
self = .unknown(type: type, raw: raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +375,9 @@ public enum GatewayFrame: Codable {
|
|||||||
case .req(let v): try v.encode(to: encoder)
|
case .req(let v): try v.encode(to: encoder)
|
||||||
case .res(let v): try v.encode(to: encoder)
|
case .res(let v): try v.encode(to: encoder)
|
||||||
case .event(let v): try v.encode(to: encoder)
|
case .event(let v): try v.encode(to: encoder)
|
||||||
|
case .unknown(_, let raw):
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ function emitGatewayFrame(): string {
|
|||||||
case "event":
|
case "event":
|
||||||
self = .event(try decodePayload(EventFrame.self, from: raw))
|
self = .event(try decodePayload(EventFrame.self, from: raw))
|
||||||
default:
|
default:
|
||||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "unknown type \(type)")
|
self = .unknown(type: type, raw: raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +147,9 @@ function emitGatewayFrame(): string {
|
|||||||
case .req(let v): try v.encode(to: encoder)
|
case .req(let v): try v.encode(to: encoder)
|
||||||
case .res(let v): try v.encode(to: encoder)
|
case .res(let v): try v.encode(to: encoder)
|
||||||
case .event(let v): try v.encode(to: encoder)
|
case .event(let v): try v.encode(to: encoder)
|
||||||
|
case .unknown(_, let raw):
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -162,6 +165,7 @@ function emitGatewayFrame(): string {
|
|||||||
return [
|
return [
|
||||||
"public enum GatewayFrame: Codable {",
|
"public enum GatewayFrame: Codable {",
|
||||||
...caseLines,
|
...caseLines,
|
||||||
|
" case unknown(type: String, raw: [String: AnyCodable])",
|
||||||
initLines,
|
initLines,
|
||||||
helper,
|
helper,
|
||||||
"}",
|
"}",
|
||||||
|
|||||||
Reference in New Issue
Block a user