Gateway: finalize WS control plane

This commit is contained in:
Peter Steinberger
2025-12-09 14:41:41 +01:00
parent 9ef1545d06
commit b2e7fb01a9
23 changed files with 5209 additions and 2495 deletions

View File

@@ -1,4 +1,3 @@
import Darwin
import Foundation
import OSLog
@@ -9,73 +8,52 @@ struct ControlRequestParams: @unchecked Sendable {
actor AgentRPC {
static let shared = AgentRPC()
struct HeartbeatEvent: Codable {
let ts: Double
let status: String
let to: String?
let preview: String?
let durationMs: Double?
let hasMedia: Bool?
let reason: String?
}
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
static let agentEventNotification = Notification.Name("clawdis.rpc.agent")
private struct ControlResponse: Decodable {
let type: String
let id: String
let ok: Bool
let payload: AnyCodable?
let error: String?
}
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
default:
let context = EncodingError.Context(
codingPath: encoder.codingPath,
debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
}
private var process: Process?
private var stdinHandle: FileHandle?
private var stdoutHandle: FileHandle?
private var buffer = Data()
private var waiters: [CheckedContinuation<String, Error>] = []
private var controlWaiters: [String: CheckedContinuation<Data, Error>] = [:]
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
private var starting = false
private let gateway = GatewayChannel()
private var configured = false
private struct RpcError: Error { let message: String }
private var gatewayURL: URL {
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
let effectivePort = port > 0 ? port : 18789
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
}
private var gatewayToken: String? {
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
}
func start() async throws {
if configured { return }
await gateway.configure(url: gatewayURL, token: gatewayToken)
configured = true
}
func shutdown() async {
// no-op for WS; socket managed by GatewayChannel
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
_ = try await controlRequest(method: "set-heartbeats", params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
return true
} catch {
logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func status() async -> (ok: Bool, error: String?) {
do {
let data = try await controlRequest(method: "status")
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["ok"] as? Bool) ?? true {
return (true, nil)
}
return (false, "status error")
} catch {
return (false, error.localizedDescription)
}
}
func send(
text: String,
@@ -84,305 +62,25 @@ actor AgentRPC {
deliver: Bool,
to: String?) async -> (ok: Bool, text: String?, error: String?)
{
if self.process?.isRunning != true {
do {
try await self.start()
} catch {
return (false, nil, "rpc worker not running: \(error.localizedDescription)")
}
}
do {
var payload: [String: Any] = [
"type": "send",
"text": text,
"session": session,
"thinking": thinking ?? "default",
"deliver": deliver,
let params: [String: AnyHashable] = [
"message": AnyHashable(text),
"sessionId": AnyHashable(session),
"thinking": AnyHashable(thinking ?? "default"),
"deliver": AnyHashable(deliver),
"to": AnyHashable(to ?? ""),
"idempotencyKey": AnyHashable(UUID().uuidString),
]
if let to { payload["to"] = to }
let data = try JSONSerialization.data(withJSONObject: payload)
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
stdinHandle.write(data)
stdinHandle.write(Data([0x0A]))
let parsed = try await self.nextJSONObject()
if let ok = parsed["ok"] as? Bool, let type = parsed["type"] as? String, type == "result" {
if ok {
if let payloadDict = parsed["payload"] as? [String: Any],
let payloads = payloadDict["payloads"] as? [[String: Any]],
let first = payloads.first,
let txt = first["text"] as? String
{
return (true, txt, nil)
}
return (true, nil, nil)
}
}
if let err = parsed["error"] as? String {
return (false, nil, err)
}
return (false, nil, "rpc returned unexpected response: \(parsed)")
_ = try await controlRequest(method: "agent", params: ControlRequestParams(raw: params))
return (true, nil, nil)
} catch {
self.logger.error("rpc send failed: \(error.localizedDescription, privacy: .public)")
await self.stop()
return (false, nil, error.localizedDescription)
}
}
func status() async -> (ok: Bool, error: String?) {
if self.process?.isRunning != true {
do {
try await self.start()
} catch {
return (false, "rpc worker not running: \(error.localizedDescription)")
}
}
do {
let payload: [String: Any] = ["type": "status"]
let data = try JSONSerialization.data(withJSONObject: payload)
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
stdinHandle.write(data)
stdinHandle.write(Data([0x0A]))
let parsed = try await self.nextJSONObject()
if let ok = parsed["ok"] as? Bool, ok { return (true, nil) }
return (false, parsed["error"] as? String ?? "rpc status failed: \(parsed)")
} catch {
self.logger.error("rpc status failed: \(error.localizedDescription, privacy: .public)")
await self.stop()
return (false, error.localizedDescription)
}
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
guard self.process?.isRunning == true else { return false }
do {
let payload: [String: Any] = ["type": "set-heartbeats", "enabled": enabled]
let data = try JSONSerialization.data(withJSONObject: payload)
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
stdinHandle.write(data)
stdinHandle.write(Data([0x0A]))
let line = try await nextLine()
let parsed = try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]
if let ok = parsed?["ok"] as? Bool, ok { return true }
return false
} catch {
self.logger.error("rpc set-heartbeats failed: \(error.localizedDescription, privacy: .public)")
await self.stop()
return false
}
}
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
if self.process?.isRunning != true {
try await self.start()
}
let id = UUID().uuidString
var frame: [String: Any] = ["type": "control-request", "id": id, "method": method]
if let params { frame["params"] = params.raw }
let data = try JSONSerialization.data(withJSONObject: frame)
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
self.controlWaiters[id] = cont
stdinHandle.write(data)
stdinHandle.write(Data([0x0A]))
}
}
// MARK: - Process lifecycle
func start() async throws {
if self.starting { return }
self.starting = true
defer { self.starting = false }
let process = Process()
let command = CommandResolver.clawdisCommand(subcommand: "rpc")
process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env")
process.arguments = Array(command.dropFirst())
process.currentDirectoryURL = URL(fileURLWithPath: CommandResolver.projectRootPath())
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
process.environment = env
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = Pipe()
try process.run()
self.process = process
self.stdinHandle = stdinPipe.fileHandleForWriting
self.stdoutHandle = stdoutPipe.fileHandleForReading
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
guard let self else { return }
let data = handle.availableData
if data.isEmpty { return }
Task { await self.ingest(data: data) }
}
Task.detached { [weak self] in
// Ensure all waiters are failed if the worker dies (e.g., crash or SIGTERM).
process.waitUntilExit()
await self?.stop()
}
}
func shutdown() async {
await self.stop()
}
private func stop() async {
self.stdoutHandle?.readabilityHandler = nil
let proc = self.process
proc?.terminate()
if let proc, proc.isRunning {
try? await Task.sleep(nanoseconds: 700_000_000)
if proc.isRunning {
kill(proc.processIdentifier, SIGKILL)
}
}
proc?.waitUntilExit()
self.process = nil
self.stdinHandle = nil
self.stdoutHandle = nil
self.buffer.removeAll(keepingCapacity: false)
let waiters = self.waiters
self.waiters.removeAll()
for waiter in waiters {
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
}
let control = self.controlWaiters
self.controlWaiters.removeAll()
for (_, waiter) in control {
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
}
}
private func ingest(data: Data) {
self.buffer.append(data)
while let range = buffer.firstRange(of: Data([0x0A])) {
let lineData = self.buffer.subdata(in: self.buffer.startIndex..<range.lowerBound)
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
// Event frames are pushed without request/response pairing (e.g., heartbeats/agent).
if self.handleEventLine(line) {
continue
}
if self.handleControlResponse(line) {
continue
}
if let waiter = waiters.first {
self.waiters.removeFirst()
waiter.resume(returning: line)
}
}
}
/// Read the next line that successfully parses as JSON. Non-JSON lines (e.g., stray stdout logs)
/// are skipped to keep the RPC bridge resilient to accidental prints.
private func nextJSONObject(maxSkips: Int = 30) async throws -> [String: Any] {
var skipped = 0
while true {
let line = try await self.nextLine()
guard let data = line.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
skipped += 1
if skipped >= maxSkips {
throw RpcError(message: "rpc returned non-JSON output: \(line)")
}
continue
}
return obj
}
}
private func parseHeartbeatEvent(from line: String) -> HeartbeatEvent? {
guard let data = line.data(using: .utf8) else { return nil }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String,
type == "event",
let evt = obj["event"] as? String,
evt == "heartbeat",
let payload = obj["payload"] as? [String: Any]
else {
return nil
}
let decoder = JSONDecoder()
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
}
private func parseAgentEvent(from line: String) -> ControlAgentEvent? {
guard let data = line.data(using: .utf8) else { return nil }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String,
type == "event",
let evt = obj["event"] as? String,
evt == "agent",
let payload = obj["payload"]
else {
return nil
}
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
return try? JSONDecoder().decode(ControlAgentEvent.self, from: payloadData)
}
private func handleEventLine(_ line: String) -> Bool {
if let hb = self.parseHeartbeatEvent(from: line) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: Self.heartbeatNotification, object: hb)
NotificationCenter.default.post(name: .controlHeartbeat, object: hb)
}
return true
}
if let agent = self.parseAgentEvent(from: line) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: Self.agentEventNotification, object: agent)
NotificationCenter.default.post(name: .controlAgentEvent, object: agent)
}
return true
}
return false
}
private func handleControlResponse(_ line: String) -> Bool {
guard let data = line.data(using: .utf8) else { return false }
guard let parsed = try? JSONDecoder().decode(ControlResponse.self, from: data) else { return false }
guard parsed.type == "control-response" else { return false }
self.logger.debug("control response parsed id=\(parsed.id, privacy: .public) ok=\(parsed.ok, privacy: .public)")
guard let waiter = self.controlWaiters.removeValue(forKey: parsed.id) else {
self.logger.debug("control response with no waiter id=\(parsed.id, privacy: .public)")
return true
}
if parsed.ok {
let payloadData: Data = {
if let payload = parsed.payload {
return (try? JSONEncoder().encode(payload)) ?? Data()
}
// Use an empty JSON array to keep callers happy when payload is missing.
return Data("[]".utf8)
}()
waiter.resume(returning: payloadData)
} else {
waiter.resume(throwing: RpcError(message: parsed.error ?? "control error"))
}
return true
}
private func nextLine() async throws -> String {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
self.waiters.append(cont)
}
try await start()
let rawParams = params?.raw.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
return try await gateway.request(method: method, params: rawParams)
}
}

View File

@@ -77,20 +77,26 @@ final class ControlChannel: ObservableObject {
case degraded(String)
}
enum Mode: Equatable {
case local
case remote(target: String, identity: String)
}
@Published private(set) var state: ConnectionState = .disconnected
@Published private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let gateway = GatewayChannel()
private var gatewayURL: URL {
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
let effectivePort = port > 0 ? port : 18789
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
}
private var gatewayToken: String? {
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
}
private var eventTokens: [NSObjectProtocol] = []
func configure() async {
do {
self.state = .connecting
try await AgentRPC.shared.start()
await gateway.configure(url: gatewayURL, token: gatewayToken)
self.startEventStream()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
@@ -98,16 +104,16 @@ final class ControlChannel: ObservableObject {
}
}
func configure(mode: Mode) async throws {
// Mode is retained for API compatibility; transport is always stdio now.
await self.configure()
}
func configure(mode _: Any? = nil) async throws { await self.configure() }
func health(timeout: TimeInterval? = nil) async throws -> Data {
let params = timeout.map { ControlRequestParams(raw: ["timeoutMs": AnyHashable(Int($0 * 1000))]) }
do {
let start = Date()
let payload = try await AgentRPC.shared.controlRequest(method: "health", params: params)
var params: [String: AnyHashable]? = nil
if let timeout {
params = ["timeout": AnyHashable(Int(timeout * 1000))]
}
let payload = try await self.request(method: "health", params: params)
let ms = Date().timeIntervalSince(start) * 1000
self.lastPingMs = ms
self.state = .connected
@@ -119,14 +125,14 @@ final class ControlChannel: ObservableObject {
}
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
let data = try await AgentRPC.shared.controlRequest(method: "last-heartbeat")
if data.isEmpty { return nil }
return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data)
// Heartbeat removed in new protocol
return nil
}
func request(method: String, params: ControlRequestParams? = nil) async throws -> Data {
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
do {
let data = try await AgentRPC.shared.controlRequest(method: method, params: params)
let rawParams = params?.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
let data = try await gateway.request(method: method, params: rawParams)
self.state = .connected
return data
} catch {
@@ -136,9 +142,48 @@ final class ControlChannel: ObservableObject {
}
func sendSystemEvent(_ text: String) async throws {
_ = try await self.request(
method: "system-event",
params: ControlRequestParams(raw: ["text": AnyHashable(text)]))
_ = try await self.request(method: "system-event", params: ["text": AnyHashable(text)])
}
private func startEventStream() {
for tok in eventTokens { NotificationCenter.default.removeObserver(tok) }
eventTokens.removeAll()
let ev = NotificationCenter.default.addObserver(
forName: .gatewayEvent,
object: nil,
queue: .main
) { note in
guard let obj = note.userInfo as? [String: Any],
let event = obj["event"] as? String else { return }
switch event {
case "agent":
if let payload = obj["payload"] as? [String: Any],
let runId = payload["runId"] as? String,
let seq = payload["seq"] as? Int,
let stream = payload["stream"] as? String,
let ts = payload["ts"] as? Double,
let dataDict = payload["data"] as? [String: Any]
{
let wrapped = dataDict.mapValues { AnyCodable($0) }
AgentEventStore.shared.append(ControlAgentEvent(runId: runId, seq: seq, stream: stream, ts: ts, data: wrapped))
}
case "presence":
// InstancesStore listens separately via notification
break
case "shutdown":
self.state = .degraded("gateway shutdown")
default:
break
}
}
let tick = NotificationCenter.default.addObserver(
forName: .gatewaySnapshot,
object: nil,
queue: .main
) { _ in
self.state = .connected
}
eventTokens = [ev, tick]
}
}

View File

@@ -0,0 +1,221 @@
import Foundation
import OSLog
struct GatewayEvent: Codable {
let type: String
let event: String?
let payload: AnyCodable?
let seq: Int?
}
extension Notification.Name {
static let gatewaySnapshot = Notification.Name("clawdis.gateway.snapshot")
static let gatewayEvent = Notification.Name("clawdis.gateway.event")
static let gatewaySeqGap = Notification.Name("clawdis.gateway.seqgap")
}
private actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
private var task: URLSessionWebSocketTask?
private var pending: [String: CheckedContinuation<Data, Error>] = [:]
private var connected = false
private var url: URL
private var token: String?
private let session = URLSession(configuration: .default)
private var backoffMs: Double = 500
private var shouldReconnect = true
private var lastSeq: Int?
init(url: URL, token: String?) {
self.url = url
self.token = token
}
func connect() async throws {
if connected, task?.state == .running { return }
task?.cancel(with: .goingAway, reason: nil)
task = session.webSocketTask(with: url)
task?.resume()
try await sendHello()
listen()
connected = true
backoffMs = 500
lastSeq = nil
}
private func sendHello() async throws {
let hello: [String: Any] = [
"type": "hello",
"minProtocol": 1,
"maxProtocol": 1,
"client": [
"name": "clawdis-mac",
"version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev",
"platform": "macos",
"mode": "app",
"instanceId": Host.current().localizedName ?? UUID().uuidString,
],
"caps": [],
"auth": token != nil ? ["token": token!] : [:],
]
let data = try JSONSerialization.data(withJSONObject: hello)
try await task?.send(.data(data))
// wait for hello-ok
if let msg = try await task?.receive() {
if try await handleHelloResponse(msg) { return }
}
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed"])
}
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws -> Bool {
let data: Data?
switch msg {
case .data(let d): data = d
case .string(let s): data = s.data(using: .utf8)
@unknown default: data = nil
}
guard let data else { return false }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String else { return false }
if type == "hello-ok" {
NotificationCenter.default.post(name: .gatewaySnapshot, object: nil, userInfo: obj)
return true
}
return false
}
private func listen() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure(let err):
self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)")
self.connected = false
self.scheduleReconnect()
case .success(let msg):
Task { await self.handle(msg) }
self.listen()
}
}
}
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
let data: Data?
switch msg {
case .data(let d): data = d
case .string(let s): data = s.data(using: .utf8)
@unknown default: data = nil
}
guard let data else { return }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String else { return }
switch type {
case "res":
if let id = obj["id"] as? String, let waiter = pending.removeValue(forKey: id) {
waiter.resume(returning: data)
}
case "event":
if let seq = obj["seq"] as? Int {
if let last = lastSeq, seq > last + 1 {
NotificationCenter.default.post(
name: .gatewaySeqGap,
object: nil,
userInfo: ["expected": last + 1, "received": seq]
)
}
lastSeq = seq
}
NotificationCenter.default.post(name: .gatewayEvent, object: nil, userInfo: obj)
case "hello-ok":
NotificationCenter.default.post(name: .gatewaySnapshot, object: nil, userInfo: obj)
default:
break
}
}
private func scheduleReconnect() {
guard shouldReconnect else { return }
let delay = backoffMs / 1000
backoffMs = min(backoffMs * 2, 30_000)
Task.detached { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard let self else { return }
do {
try await self.connect()
} catch {
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
self.scheduleReconnect()
}
}
}
func request(method: String, params: [String: Any]?) async throws -> Data {
try await connect()
let id = UUID().uuidString
let frame: [String: Any] = [
"type": "req",
"id": id,
"method": method,
"params": params ?? [:],
]
let data = try JSONSerialization.data(withJSONObject: frame)
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
pending[id] = cont
Task {
do {
try await task?.send(.data(data))
} catch {
pending.removeValue(forKey: id)
cont.resume(throwing: error)
}
}
}
return response
}
}
actor GatewayChannel {
private var inner: GatewayChannelActor?
func configure(url: URL, token: String?) {
inner = GatewayChannelActor(url: url, token: token)
}
func request(method: String, params: [String: Any]?) async throws -> Data {
guard let inner else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "not configured"])
}
return try await inner.request(method: method, params: params)
}
}
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
default:
let ctx = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, ctx)
}
}
}

View File

@@ -36,9 +36,11 @@ final class InstancesStore: ObservableObject {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
private var task: Task<Void, Never>?
private let interval: TimeInterval = 30
private var observers: [NSObjectProtocol] = []
func start() {
guard self.task == nil else { return }
self.observeGatewayEvents()
self.task = Task.detached { [weak self] in
guard let self else { return }
await self.refresh()
@@ -52,6 +54,45 @@ final class InstancesStore: ObservableObject {
func stop() {
self.task?.cancel()
self.task = nil
for token in observers {
NotificationCenter.default.removeObserver(token)
}
observers.removeAll()
}
private func observeGatewayEvents() {
let ev = NotificationCenter.default.addObserver(
forName: .gatewayEvent,
object: nil,
queue: .main
) { [weak self] note in
guard let self,
let obj = note.userInfo as? [String: Any],
let event = obj["event"] as? String else { return }
if event == "presence", let payload = obj["payload"] as? [String: Any] {
self.handlePresencePayload(payload)
}
}
let gap = NotificationCenter.default.addObserver(
forName: .gatewaySeqGap,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { await self.refresh() }
}
let snap = NotificationCenter.default.addObserver(
forName: .gatewaySnapshot,
object: nil,
queue: .main
) { [weak self] note in
guard let self,
let obj = note.userInfo as? [String: Any],
let snapshot = obj["snapshot"] as? [String: Any],
let presence = snapshot["presence"] else { return }
self.decodeAndApplyPresence(presence: presence)
}
observers = [ev, snap, gap]
}
func refresh() async {
@@ -213,4 +254,35 @@ final class InstancesStore: ObservableObject {
}
}
}
private func handlePresencePayload(_ payload: [String: Any]) {
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 {
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
let withIDs = decoded.map { entry -> InstanceInfo in
let key = entry.host ?? entry.ip ?? entry.text
return InstanceInfo(
id: key,
host: entry.host,
ip: entry.ip,
version: entry.version,
lastInputSeconds: entry.lastInputSeconds,
mode: entry.mode,
reason: entry.reason,
text: entry.text,
ts: entry.ts)
}
self.instances = withIDs
self.statusMessage = nil
self.lastError = nil
} catch {
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
}
}
}

View File

@@ -89,12 +89,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
RelayProcessManager.shared.setActive(!state.isPaused)
}
Task {
let controlMode: ControlChannel.Mode = AppStateStore.shared.connectionMode == .remote
? .remote(target: AppStateStore.shared.remoteTarget, identity: AppStateStore.shared.remoteIdentity)
: .local
try? await ControlChannel.shared.configure(mode: controlMode)
try? await AgentRPC.shared.start()
_ = await AgentRPC.shared.setHeartbeatsEnabled(AppStateStore.shared.heartbeatsEnabled)
try? await ControlChannel.shared.configure()
PresenceReporter.shared.start()
}
Task { await HealthStore.shared.refresh(onDemand: true) }

File diff suppressed because it is too large Load Diff