mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Gateway: finalize WS control plane
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
apps/macos/Sources/Clawdis/GatewayChannel.swift
Normal file
221
apps/macos/Sources/Clawdis/GatewayChannel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
895
apps/macos/Sources/ClawdisProtocol/Protocol.swift
Normal file
895
apps/macos/Sources/ClawdisProtocol/Protocol.swift
Normal file
@@ -0,0 +1,895 @@
|
||||
// This file was generated from JSON Schema using quicktype, do not modify it directly.
|
||||
// To parse the JSON, add this file to your project and do:
|
||||
//
|
||||
// let clawdisGateway = try ClawdisGateway(json)
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Handshake, request/response, and event frames for the Gateway WebSocket.
|
||||
// MARK: - ClawdisGateway
|
||||
struct ClawdisGateway: Codable {
|
||||
let auth: Auth?
|
||||
let caps: [String]?
|
||||
let client: Client?
|
||||
let locale: String?
|
||||
let maxProtocol, minProtocol: Int?
|
||||
let type: TypeEnum
|
||||
let userAgent: String?
|
||||
let features: Features?
|
||||
let policy: Policy?
|
||||
let clawdisGatewayProtocol: Int?
|
||||
let server: Server?
|
||||
let snapshot: Snapshot?
|
||||
let expectedProtocol: Int?
|
||||
let minClient, reason, id, method: String?
|
||||
let params: JSONAny?
|
||||
let error: Error?
|
||||
let ok: Bool?
|
||||
let payload: JSONAny?
|
||||
let event: String?
|
||||
let seq: Int?
|
||||
let stateVersion: ClawdisGatewayStateVersion?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case auth, caps, client, locale, maxProtocol, minProtocol, type, userAgent, features, policy
|
||||
case clawdisGatewayProtocol = "protocol"
|
||||
case server, snapshot, expectedProtocol, minClient, reason, id, method, params, error, ok, payload, event, seq, stateVersion
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ClawdisGateway convenience initializers and mutators
|
||||
|
||||
extension ClawdisGateway {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(ClawdisGateway.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
auth: Auth?? = nil,
|
||||
caps: [String]?? = nil,
|
||||
client: Client?? = nil,
|
||||
locale: String?? = nil,
|
||||
maxProtocol: Int?? = nil,
|
||||
minProtocol: Int?? = nil,
|
||||
type: TypeEnum? = nil,
|
||||
userAgent: String?? = nil,
|
||||
features: Features?? = nil,
|
||||
policy: Policy?? = nil,
|
||||
clawdisGatewayProtocol: Int?? = nil,
|
||||
server: Server?? = nil,
|
||||
snapshot: Snapshot?? = nil,
|
||||
expectedProtocol: Int?? = nil,
|
||||
minClient: String?? = nil,
|
||||
reason: String?? = nil,
|
||||
id: String?? = nil,
|
||||
method: String?? = nil,
|
||||
params: JSONAny?? = nil,
|
||||
error: Error?? = nil,
|
||||
ok: Bool?? = nil,
|
||||
payload: JSONAny?? = nil,
|
||||
event: String?? = nil,
|
||||
seq: Int?? = nil,
|
||||
stateVersion: ClawdisGatewayStateVersion?? = nil
|
||||
) -> ClawdisGateway {
|
||||
return ClawdisGateway(
|
||||
auth: auth ?? self.auth,
|
||||
caps: caps ?? self.caps,
|
||||
client: client ?? self.client,
|
||||
locale: locale ?? self.locale,
|
||||
maxProtocol: maxProtocol ?? self.maxProtocol,
|
||||
minProtocol: minProtocol ?? self.minProtocol,
|
||||
type: type ?? self.type,
|
||||
userAgent: userAgent ?? self.userAgent,
|
||||
features: features ?? self.features,
|
||||
policy: policy ?? self.policy,
|
||||
clawdisGatewayProtocol: clawdisGatewayProtocol ?? self.clawdisGatewayProtocol,
|
||||
server: server ?? self.server,
|
||||
snapshot: snapshot ?? self.snapshot,
|
||||
expectedProtocol: expectedProtocol ?? self.expectedProtocol,
|
||||
minClient: minClient ?? self.minClient,
|
||||
reason: reason ?? self.reason,
|
||||
id: id ?? self.id,
|
||||
method: method ?? self.method,
|
||||
params: params ?? self.params,
|
||||
error: error ?? self.error,
|
||||
ok: ok ?? self.ok,
|
||||
payload: payload ?? self.payload,
|
||||
event: event ?? self.event,
|
||||
seq: seq ?? self.seq,
|
||||
stateVersion: stateVersion ?? self.stateVersion
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
struct Auth: Codable {
|
||||
let token: String?
|
||||
}
|
||||
|
||||
// MARK: Auth convenience initializers and mutators
|
||||
|
||||
extension Auth {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Auth.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
token: String?? = nil
|
||||
) -> Auth {
|
||||
return Auth(
|
||||
token: token ?? self.token
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
struct Client: Codable {
|
||||
let instanceID: String?
|
||||
let mode, name, platform, version: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case instanceID = "instanceId"
|
||||
case mode, name, platform, version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Client convenience initializers and mutators
|
||||
|
||||
extension Client {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Client.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
instanceID: String?? = nil,
|
||||
mode: String? = nil,
|
||||
name: String? = nil,
|
||||
platform: String? = nil,
|
||||
version: String? = nil
|
||||
) -> Client {
|
||||
return Client(
|
||||
instanceID: instanceID ?? self.instanceID,
|
||||
mode: mode ?? self.mode,
|
||||
name: name ?? self.name,
|
||||
platform: platform ?? self.platform,
|
||||
version: version ?? self.version
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
struct Error: Codable {
|
||||
let code: String
|
||||
let details: JSONAny?
|
||||
let message: String
|
||||
let retryable: Bool?
|
||||
let retryAfterMS: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case code, details, message, retryable
|
||||
case retryAfterMS = "retryAfterMs"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Error convenience initializers and mutators
|
||||
|
||||
extension Error {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Error.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
code: String? = nil,
|
||||
details: JSONAny?? = nil,
|
||||
message: String? = nil,
|
||||
retryable: Bool?? = nil,
|
||||
retryAfterMS: Int?? = nil
|
||||
) -> Error {
|
||||
return Error(
|
||||
code: code ?? self.code,
|
||||
details: details ?? self.details,
|
||||
message: message ?? self.message,
|
||||
retryable: retryable ?? self.retryable,
|
||||
retryAfterMS: retryAfterMS ?? self.retryAfterMS
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Features
|
||||
struct Features: Codable {
|
||||
let events, methods: [String]
|
||||
}
|
||||
|
||||
// MARK: Features convenience initializers and mutators
|
||||
|
||||
extension Features {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Features.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
events: [String]? = nil,
|
||||
methods: [String]? = nil
|
||||
) -> Features {
|
||||
return Features(
|
||||
events: events ?? self.events,
|
||||
methods: methods ?? self.methods
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Policy
|
||||
struct Policy: Codable {
|
||||
let maxBufferedBytes, maxPayload, tickIntervalMS: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case maxBufferedBytes, maxPayload
|
||||
case tickIntervalMS = "tickIntervalMs"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Policy convenience initializers and mutators
|
||||
|
||||
extension Policy {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Policy.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
maxBufferedBytes: Int? = nil,
|
||||
maxPayload: Int? = nil,
|
||||
tickIntervalMS: Int? = nil
|
||||
) -> Policy {
|
||||
return Policy(
|
||||
maxBufferedBytes: maxBufferedBytes ?? self.maxBufferedBytes,
|
||||
maxPayload: maxPayload ?? self.maxPayload,
|
||||
tickIntervalMS: tickIntervalMS ?? self.tickIntervalMS
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server
|
||||
struct Server: Codable {
|
||||
let commit: String?
|
||||
let connID: String
|
||||
let host: String?
|
||||
let version: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commit
|
||||
case connID = "connId"
|
||||
case host, version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Server convenience initializers and mutators
|
||||
|
||||
extension Server {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Server.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
commit: String?? = nil,
|
||||
connID: String? = nil,
|
||||
host: String?? = nil,
|
||||
version: String? = nil
|
||||
) -> Server {
|
||||
return Server(
|
||||
commit: commit ?? self.commit,
|
||||
connID: connID ?? self.connID,
|
||||
host: host ?? self.host,
|
||||
version: version ?? self.version
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
struct Snapshot: Codable {
|
||||
let health: JSONAny
|
||||
let presence: [Presence]
|
||||
let stateVersion: SnapshotStateVersion
|
||||
let uptimeMS: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case health, presence, stateVersion
|
||||
case uptimeMS = "uptimeMs"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Snapshot convenience initializers and mutators
|
||||
|
||||
extension Snapshot {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Snapshot.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
health: JSONAny? = nil,
|
||||
presence: [Presence]? = nil,
|
||||
stateVersion: SnapshotStateVersion? = nil,
|
||||
uptimeMS: Int? = nil
|
||||
) -> Snapshot {
|
||||
return Snapshot(
|
||||
health: health ?? self.health,
|
||||
presence: presence ?? self.presence,
|
||||
stateVersion: stateVersion ?? self.stateVersion,
|
||||
uptimeMS: uptimeMS ?? self.uptimeMS
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presence
|
||||
struct Presence: Codable {
|
||||
let host, instanceID, ip: String?
|
||||
let lastInputSeconds: Int?
|
||||
let mode, reason: String?
|
||||
let tags: [String]?
|
||||
let text: String?
|
||||
let ts: Int
|
||||
let version: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case host
|
||||
case instanceID = "instanceId"
|
||||
case ip, lastInputSeconds, mode, reason, tags, text, ts, version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Presence convenience initializers and mutators
|
||||
|
||||
extension Presence {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Presence.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
host: String?? = nil,
|
||||
instanceID: String?? = nil,
|
||||
ip: String?? = nil,
|
||||
lastInputSeconds: Int?? = nil,
|
||||
mode: String?? = nil,
|
||||
reason: String?? = nil,
|
||||
tags: [String]?? = nil,
|
||||
text: String?? = nil,
|
||||
ts: Int? = nil,
|
||||
version: String?? = nil
|
||||
) -> Presence {
|
||||
return Presence(
|
||||
host: host ?? self.host,
|
||||
instanceID: instanceID ?? self.instanceID,
|
||||
ip: ip ?? self.ip,
|
||||
lastInputSeconds: lastInputSeconds ?? self.lastInputSeconds,
|
||||
mode: mode ?? self.mode,
|
||||
reason: reason ?? self.reason,
|
||||
tags: tags ?? self.tags,
|
||||
text: text ?? self.text,
|
||||
ts: ts ?? self.ts,
|
||||
version: version ?? self.version
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SnapshotStateVersion
|
||||
struct SnapshotStateVersion: Codable {
|
||||
let health, presence: Int
|
||||
}
|
||||
|
||||
// MARK: SnapshotStateVersion convenience initializers and mutators
|
||||
|
||||
extension SnapshotStateVersion {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(SnapshotStateVersion.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
health: Int? = nil,
|
||||
presence: Int? = nil
|
||||
) -> SnapshotStateVersion {
|
||||
return SnapshotStateVersion(
|
||||
health: health ?? self.health,
|
||||
presence: presence ?? self.presence
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ClawdisGatewayStateVersion
|
||||
struct ClawdisGatewayStateVersion: Codable {
|
||||
let health, presence: Int
|
||||
}
|
||||
|
||||
// MARK: ClawdisGatewayStateVersion convenience initializers and mutators
|
||||
|
||||
extension ClawdisGatewayStateVersion {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(ClawdisGatewayStateVersion.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
health: Int? = nil,
|
||||
presence: Int? = nil
|
||||
) -> ClawdisGatewayStateVersion {
|
||||
return ClawdisGatewayStateVersion(
|
||||
health: health ?? self.health,
|
||||
presence: presence ?? self.presence
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
enum TypeEnum: String, Codable {
|
||||
case event = "event"
|
||||
case hello = "hello"
|
||||
case helloError = "hello-error"
|
||||
case helloOk = "hello-ok"
|
||||
case req = "req"
|
||||
case res = "res"
|
||||
}
|
||||
|
||||
// MARK: - Helper functions for creating encoders and decoders
|
||||
|
||||
func newJSONDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
|
||||
func newJSONEncoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
}
|
||||
return encoder
|
||||
}
|
||||
|
||||
// MARK: - Encode/decode helpers
|
||||
|
||||
class JSONNull: Codable, Hashable {
|
||||
|
||||
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
public var hashValue: Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if !container.decodeNil() {
|
||||
throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull"))
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
class JSONCodingKey: CodingKey {
|
||||
let key: String
|
||||
|
||||
required init?(intValue: Int) {
|
||||
return nil
|
||||
}
|
||||
|
||||
required init?(stringValue: String) {
|
||||
key = stringValue
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stringValue: String {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
class JSONAny: Codable {
|
||||
|
||||
let value: Any
|
||||
|
||||
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
|
||||
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
|
||||
return DecodingError.typeMismatch(JSONAny.self, context)
|
||||
}
|
||||
|
||||
static func encodingError(forValue value: Any, codingPath: [CodingKey]) -> EncodingError {
|
||||
let context = EncodingError.Context(codingPath: codingPath, debugDescription: "Cannot encode JSONAny")
|
||||
return EncodingError.invalidValue(value, context)
|
||||
}
|
||||
|
||||
static func decode(from container: SingleValueDecodingContainer) throws -> Any {
|
||||
if let value = try? container.decode(Bool.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int64.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(String.self) {
|
||||
return value
|
||||
}
|
||||
if container.decodeNil() {
|
||||
return JSONNull()
|
||||
}
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decode(from container: inout UnkeyedDecodingContainer) throws -> Any {
|
||||
if let value = try? container.decode(Bool.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int64.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(String.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decodeNil() {
|
||||
if value {
|
||||
return JSONNull()
|
||||
}
|
||||
}
|
||||
if var container = try? container.nestedUnkeyedContainer() {
|
||||
return try decodeArray(from: &container)
|
||||
}
|
||||
if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self) {
|
||||
return try decodeDictionary(from: &container)
|
||||
}
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decode(from container: inout KeyedDecodingContainer<JSONCodingKey>, forKey key: JSONCodingKey) throws -> Any {
|
||||
if let value = try? container.decode(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int64.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decodeNil(forKey: key) {
|
||||
if value {
|
||||
return JSONNull()
|
||||
}
|
||||
}
|
||||
if var container = try? container.nestedUnkeyedContainer(forKey: key) {
|
||||
return try decodeArray(from: &container)
|
||||
}
|
||||
if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) {
|
||||
return try decodeDictionary(from: &container)
|
||||
}
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decodeArray(from container: inout UnkeyedDecodingContainer) throws -> [Any] {
|
||||
var arr: [Any] = []
|
||||
while !container.isAtEnd {
|
||||
let value = try decode(from: &container)
|
||||
arr.append(value)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
static func decodeDictionary(from container: inout KeyedDecodingContainer<JSONCodingKey>) throws -> [String: Any] {
|
||||
var dict = [String: Any]()
|
||||
for key in container.allKeys {
|
||||
let value = try decode(from: &container, forKey: key)
|
||||
dict[key.stringValue] = value
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
static func encode(to container: inout UnkeyedEncodingContainer, array: [Any]) throws {
|
||||
for value in array {
|
||||
if let value = value as? Bool {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Int64 {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Double {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? String {
|
||||
try container.encode(value)
|
||||
} else if value is JSONNull {
|
||||
try container.encodeNil()
|
||||
} else if let value = value as? [Any] {
|
||||
var container = container.nestedUnkeyedContainer()
|
||||
try encode(to: &container, array: value)
|
||||
} else if let value = value as? [String: Any] {
|
||||
var container = container.nestedContainer(keyedBy: JSONCodingKey.self)
|
||||
try encode(to: &container, dictionary: value)
|
||||
} else {
|
||||
throw encodingError(forValue: value, codingPath: container.codingPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func encode(to container: inout KeyedEncodingContainer<JSONCodingKey>, dictionary: [String: Any]) throws {
|
||||
for (key, value) in dictionary {
|
||||
let key = JSONCodingKey(stringValue: key)!
|
||||
if let value = value as? Bool {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if let value = value as? Int64 {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if let value = value as? Double {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if let value = value as? String {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if value is JSONNull {
|
||||
try container.encodeNil(forKey: key)
|
||||
} else if let value = value as? [Any] {
|
||||
var container = container.nestedUnkeyedContainer(forKey: key)
|
||||
try encode(to: &container, array: value)
|
||||
} else if let value = value as? [String: Any] {
|
||||
var container = container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key)
|
||||
try encode(to: &container, dictionary: value)
|
||||
} else {
|
||||
throw encodingError(forValue: value, codingPath: container.codingPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func encode(to container: inout SingleValueEncodingContainer, value: Any) throws {
|
||||
if let value = value as? Bool {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Int64 {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Double {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? String {
|
||||
try container.encode(value)
|
||||
} else if value is JSONNull {
|
||||
try container.encodeNil()
|
||||
} else {
|
||||
throw encodingError(forValue: value, codingPath: container.codingPath)
|
||||
}
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
if var arrayContainer = try? decoder.unkeyedContainer() {
|
||||
self.value = try JSONAny.decodeArray(from: &arrayContainer)
|
||||
} else if var container = try? decoder.container(keyedBy: JSONCodingKey.self) {
|
||||
self.value = try JSONAny.decodeDictionary(from: &container)
|
||||
} else {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.value = try JSONAny.decode(from: container)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
if let arr = self.value as? [Any] {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try JSONAny.encode(to: &container, array: arr)
|
||||
} else if let dict = self.value as? [String: Any] {
|
||||
var container = encoder.container(keyedBy: JSONCodingKey.self)
|
||||
try JSONAny.encode(to: &container, dictionary: dict)
|
||||
} else {
|
||||
var container = encoder.singleValueContainer()
|
||||
try JSONAny.encode(to: &container, value: self.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
docs/architecture.md
Normal file
82
docs/architecture.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Gateway Architecture (target state)
|
||||
|
||||
Last updated: 2025-12-09
|
||||
|
||||
## Overview
|
||||
- A single long-lived **Gateway** process owns all messaging surfaces (WhatsApp via Baileys, Telegram when enabled) and the control/event plane.
|
||||
- All clients (macOS app, CLI, web UI, automations) connect to the Gateway over one transport: **WebSocket on 127.0.0.1:18789** (tunnel or VPN for remote).
|
||||
- One Gateway per host; it is the only place that is allowed to open a WhatsApp session. All sends/agent runs go through it.
|
||||
|
||||
## Components and flows
|
||||
- **Gateway (daemon)**
|
||||
- Maintains Baileys/Telegram connections.
|
||||
- Exposes a typed WS API (req/resp + server push events).
|
||||
- Validates every inbound frame against JSON Schema; rejects anything before a mandatory `hello`.
|
||||
- **Clients (mac app / CLI / web admin)**
|
||||
- One WS connection per client.
|
||||
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
||||
- **Agent runner (Tau/Pi process)**
|
||||
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
|
||||
- **WebChat**
|
||||
- Serves static assets locally.
|
||||
- Holds a single WS connection to the Gateway for control/data; all sends/agent runs go through the Gateway WS.
|
||||
- Remote use goes through the same SSH/Tailscale tunnel as other clients.
|
||||
|
||||
## Connection lifecycle (single client)
|
||||
```
|
||||
Client Gateway
|
||||
| |
|
||||
|------- hello ----------->|
|
||||
|<------ hello-ok ---------| (or hello-error + close)
|
||||
| (hello-ok carries snapshot: presence + health)
|
||||
| |
|
||||
|<------ event:presence ---| (deltas)
|
||||
|<------ event:tick -------| (keepalive/no-op)
|
||||
| |
|
||||
|------- req:agent ------->|
|
||||
|<------ res:agent --------| (ack: {runId,status:"accepted"})
|
||||
|<------ event:agent ------| (streaming)
|
||||
|<------ res:agent --------| (final: {runId,status,summary})
|
||||
| |
|
||||
```
|
||||
## Wire protocol (summary)
|
||||
- Transport: WebSocket, text frames with JSON payloads.
|
||||
- First frame must be `hello {type:"hello", minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? }`.
|
||||
- Server replies `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence:[...], health:{...}, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload,maxBufferedBytes,tickIntervalMs} }`
|
||||
or `hello-error {type:"hello-error", reason, expectedProtocol, minClient }` then closes.
|
||||
- After handshake:
|
||||
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
||||
- Events: `{type:"event", event:"agent"|"presence"|"tick"|"shutdown", payload, seq?, stateVersion?}`
|
||||
- If `CLAWDIS_GATEWAY_TOKEN` (or `--token`) is set, `hello.auth.token` must match; otherwise the socket closes with policy violation.
|
||||
- Presence payload is structured, not free text: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
||||
- Agent runs are acked `{runId,status:"accepted"}` then complete with a final res `{runId,status,summary}`; streamed output arrives as `event:"agent"`.
|
||||
- Protocol versions are bumped on breaking changes; clients must match `minClient`; Gateway chooses within client’s min/max.
|
||||
- Idempotency keys are required for side-effecting methods (`send`, `agent`) to safely retry; server keeps a short-lived dedupe cache.
|
||||
- Policy in `hello-ok` communicates payload/queue limits and tick interval.
|
||||
|
||||
## Type system and codegen
|
||||
- Source of truth: TypeBox (or ArkType) definitions in `protocol/` on the server.
|
||||
- Build step emits JSON Schema.
|
||||
- Clients:
|
||||
- TypeScript: uses the same TypeBox types directly.
|
||||
- Swift: generated `Codable` models via quicktype from the JSON Schema.
|
||||
- Validation: AJV on the server for every inbound frame; optional client-side validation for defensive programming.
|
||||
|
||||
## Invariants
|
||||
- Exactly one Gateway controls a single Baileys session per host. No fallbacks to ad-hoc direct Baileys sends.
|
||||
- Handshake is mandatory; any non-JSON or non-hello first frame is a hard close.
|
||||
- All methods and events are versioned; new fields are additive; breaking changes increment `protocol`.
|
||||
- No event replay: on seq gaps, clients must refresh (`health` + `system-presence`) and continue; presence is bounded via TTL/max entries.
|
||||
|
||||
## Remote access
|
||||
- Preferred: Tailscale or VPN; alternate: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host`.
|
||||
- Same protocol over the tunnel; same handshake. If a shared token is configured, clients must send it in `hello.auth.token` even over the tunnel.
|
||||
|
||||
## Operations snapshot
|
||||
- Start: `clawdis gateway` (foreground, logs to stdout).
|
||||
Supervise with launchd/systemd for restarts.
|
||||
- Health: request `health` over WS; also surfaced in `hello-ok.health`.
|
||||
- Metrics/logging: keep outside this spec; gateway should expose Prometheus text or structured logs separately.
|
||||
|
||||
## Migration notes
|
||||
- This architecture supersedes the legacy stdin RPC and the ad-hoc TCP control port. New clients should speak only the WS protocol. Legacy compatibility is intentionally dropped.
|
||||
126
docs/gateway.md
Normal file
126
docs/gateway.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Gateway (daemon) runbook
|
||||
|
||||
Last updated: 2025-12-09
|
||||
|
||||
## What it is
|
||||
- The always-on process that owns the single Baileys/Telegram connection and the control/event plane.
|
||||
- Replaces the legacy `relay` command. CLI entry point: `clawdis gateway`.
|
||||
- Runs until stopped; exits non-zero on fatal errors so the supervisor restarts it.
|
||||
|
||||
## How to run (local)
|
||||
```bash
|
||||
clawdis gateway --port 18789
|
||||
```
|
||||
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
|
||||
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
|
||||
- Optional shared secret: pass `--token <value>` or set `CLAWDIS_GATEWAY_TOKEN` to require clients to send `hello.auth.token`.
|
||||
|
||||
## Remote access
|
||||
- Tailscale/VPN preferred; otherwise SSH tunnel:
|
||||
```bash
|
||||
ssh -N -L 18789:127.0.0.1:18789 user@host
|
||||
```
|
||||
- Clients then connect to `ws://127.0.0.1:18789` through the tunnel.
|
||||
- If a token is configured, clients must include it in `hello.auth.token` even over the tunnel.
|
||||
|
||||
## Protocol (operator view)
|
||||
- Mandatory first frame from client: `hello {type:"hello", minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? }`.
|
||||
- Gateway replies `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion, uptimeMs}, policy:{maxPayload,maxBufferedBytes,tickIntervalMs} }` or `hello-error`.
|
||||
- After handshake:
|
||||
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
||||
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
|
||||
- Structured presence entries: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
||||
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
|
||||
|
||||
## Methods (initial set)
|
||||
- `health` — full health snapshot (same shape as `clawdis health --json`).
|
||||
- `status` — short summary.
|
||||
- `system-presence` — current presence list.
|
||||
- `system-event` — post a presence/system note (structured).
|
||||
- `send` — send a message via the active provider(s).
|
||||
- `agent` — run an agent turn (streams events back on same connection).
|
||||
|
||||
## Events
|
||||
- `agent` — streamed tool/output events from the agent run (seq-tagged).
|
||||
- `presence` — presence updates (deltas with stateVersion) pushed to all connected clients.
|
||||
- `tick` — periodic keepalive/no-op to confirm liveness.
|
||||
- `shutdown` — Gateway is exiting; payload includes `reason` and optional `restartExpectedMs`. Clients should reconnect.
|
||||
|
||||
## WebChat integration
|
||||
- WebChat serves static assets locally (default port 18788, configurable).
|
||||
- The WebChat backend keeps a single WS connection to the Gateway for control/data; all sends and agent runs flow through that connection.
|
||||
- Remote use goes through the same SSH/Tailscale tunnel; if a gateway token is configured, WebChat must include it during hello.
|
||||
- macOS app also connects via this WS (one socket); it hydrates presence from the initial snapshot and listens for `presence` events to update the UI.
|
||||
|
||||
## Typing and validation
|
||||
- Server validates every inbound frame with AJV against JSON Schema emitted from the protocol definitions.
|
||||
- Clients (TS/Swift) consume generated types (TS directly; Swift via quicktype from the JSON Schema).
|
||||
- Types live in `src/gateway/protocol/*.ts`; regenerate schemas/models with `pnpm protocol:gen` (writes `dist/protocol.schema.json` and `apps/macos/Sources/ClawdisProtocol/Protocol.swift`).
|
||||
|
||||
## Connection snapshot
|
||||
- `hello-ok` includes a `snapshot` with `presence`, `health`, `stateVersion`, and `uptimeMs` plus `policy {maxPayload,maxBufferedBytes,tickIntervalMs}` so clients can render immediately without extra requests.
|
||||
- `health`/`system-presence` remain available for manual refresh, but are not required at connect time.
|
||||
|
||||
## Error codes (res.error shape)
|
||||
- Errors use `{ code, message, details?, retryable?, retryAfterMs? }`.
|
||||
- Standard codes:
|
||||
- `NOT_LINKED` — WhatsApp not authenticated.
|
||||
- `AGENT_TIMEOUT` — agent did not respond within the configured deadline.
|
||||
- `INVALID_REQUEST` — schema/param validation failed.
|
||||
- `UNAVAILABLE` — Gateway is shutting down or a dependency is unavailable.
|
||||
|
||||
## Keepalive behavior
|
||||
- `tick` events (or WS ping/pong) are emitted periodically so clients know the Gateway is alive even when no traffic occurs.
|
||||
- Send/agent acknowledgements remain separate responses; do not overload ticks for sends.
|
||||
|
||||
## Replay / gaps
|
||||
- Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap.
|
||||
|
||||
## Supervision (macOS example)
|
||||
- Use launchd to keep the daemon alive:
|
||||
- Program: path to `clawdis`
|
||||
- Arguments: `gateway`
|
||||
- KeepAlive: true
|
||||
- StandardOut/Err: file paths or `syslog`
|
||||
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
|
||||
|
||||
## Supervision (systemd example)
|
||||
```
|
||||
[Unit]
|
||||
Description=Clawdis Gateway
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/clawdis gateway --port 18789
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=clawdis
|
||||
Environment=CLAWDIS_GATEWAY_TOKEN=
|
||||
WorkingDirectory=/home/clawdis
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
Enable with `systemctl enable --now clawdis-gateway.service`.
|
||||
|
||||
## Operational checks
|
||||
- Liveness: open WS and send `hello` → expect `hello-ok` (with snapshot).
|
||||
- Readiness: call `health` → expect `ok: true` and `web.linked=true`.
|
||||
- Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients.
|
||||
|
||||
## Safety guarantees
|
||||
- Only one Gateway per host; all sends/agent calls must go through it.
|
||||
- No fallback to direct Baileys connections; if the Gateway is down, sends fail fast.
|
||||
- Non-hello first frames or malformed JSON are rejected and the socket is closed.
|
||||
- Graceful shutdown: emit `shutdown` event before closing; clients must handle close + reconnect.
|
||||
|
||||
## CLI helpers
|
||||
- `clawdis gw:health` / `gw:status` — request health/status over the Gateway WS.
|
||||
- `clawdis gw:send --to <num> --message "hi" [--media-url ...]` — send via Gateway (idempotent).
|
||||
- `clawdis gw:agent --message "hi" [--to ...]` — run an agent turn (waits for final by default).
|
||||
- `clawdis gw:call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
||||
|
||||
## Migration guidance
|
||||
- Retire uses of `clawdis relay` and the legacy TCP control port.
|
||||
- Update clients to speak the WS protocol with mandatory hello and structured presence.
|
||||
167
docs/refactor/new-arch.md
Normal file
167
docs/refactor/new-arch.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# New Gateway Architecture – Implementation Plan (detailed)
|
||||
|
||||
Last updated: 2025-12-09
|
||||
|
||||
Goal: replace legacy relay/stdin/TCP control with a single WebSocket Gateway, typed protocol, and first-frame snapshot. No backward compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Foundations
|
||||
- **Naming**: CLI subcommand `clawdis gateway`; internal namespace `Gateway`.
|
||||
- **Protocol folder**: create `protocol/` for schemas and build artifacts. ✅ `src/gateway/protocol`.
|
||||
- **Schema tooling**:
|
||||
- Prefer **TypeBox** (or ArkType) as source-of-truth types. ✅ TypeBox in `schema.ts`.
|
||||
- `pnpm protocol:gen`:
|
||||
1) emits JSON Schema (`dist/protocol.schema.json`),
|
||||
2) runs quicktype → Swift `Codable` models (`apps/macos/Sources/ClawdisProtocol/Protocol.swift`). ✅
|
||||
- AJV compile step for server validators. ✅
|
||||
- **CI**: add a job that fails if schema or generated Swift is stale. ✅ `pnpm protocol:check` (runs gen + git diff).
|
||||
|
||||
## Phase 1 — Protocol specification
|
||||
- Frames (WS text JSON, all with explicit `type`):
|
||||
- `hello {type:"hello", minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}`
|
||||
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
||||
- `hello-error {type:"hello-error", reason, expectedProtocol, minClient}`
|
||||
- `req {type:"req", id, method, params?}`
|
||||
- `res {type:"res", id, ok, payload?, error?}` where `error` = `{code,message,details?,retryable?,retryAfterMs?}`
|
||||
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
||||
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
|
||||
- Payload types:
|
||||
- `PresenceEntry {host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
|
||||
- `HealthSnapshot` (match existing `clawdis health --json` fields)
|
||||
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
|
||||
- `TickEvent {ts}`
|
||||
- `ShutdownEvent {reason, restartExpectedMs?}`
|
||||
- Error codes: `NOT_LINKED`, `AGENT_TIMEOUT`, `INVALID_REQUEST`, `UNAVAILABLE`.
|
||||
- Error shape: `{code, message, details?, retryable?, retryAfterMs?}`
|
||||
- Rules:
|
||||
- First frame must be `type:"hello"`; otherwise close. Add handshake timeout (e.g., 3s) for silent clients.
|
||||
- Negotiate protocol: server picks within `[minProtocol,maxProtocol]`; if none, send `hello-error`.
|
||||
- Protocol version bump on breaking changes; `hello-ok` must include `minClient` when needed.
|
||||
- `stateVersion` increments for presence/health to drop stale deltas.
|
||||
- Stable IDs: client sends `instanceId`; server issues per-connection `connId` in `hello-ok`; presence entries may include `instanceId` to dedupe reconnects.
|
||||
- Token-based auth: bearer token in `auth.token`; required except for loopback development.
|
||||
- Presence is primarily connection-derived; client may add hints (e.g., lastInputSeconds); entries expire via TTL to keep the map bounded (e.g., 5m TTL, max 200 entries).
|
||||
- Idempotency keys: required for `send` and `agent` to safely retry after disconnects.
|
||||
- Size limits: bound first-frame size by `maxPayload`; reject early if exceeded.
|
||||
- Close on any non-JSON or wrong `type` before hello.
|
||||
- Per-op idempotency keys: client SHOULD supply an explicit key per `send`/`agent`; if omitted, server may derive a scoped key from `instanceId+connId`, but explicit keys are safer across reconnects.
|
||||
- Locale/userAgent are informational; server may log them for analytics but must not rely on them for access control.
|
||||
|
||||
## Phase 2 — Gateway WebSocket server
|
||||
- New module `src/gateway/server.ts`:
|
||||
- Bind 127.0.0.1:18789 (configurable).
|
||||
- On connect: validate `hello`, send `hello-ok` with snapshot, start event pump.
|
||||
- Per-connection queues with backpressure (bounded; drop oldest non-critical).
|
||||
- WS-level caps: set `maxPayload` to cap frame size before JSON parse.
|
||||
- Emit `tick` every N seconds when idle (or WS ping/pong if adequate).
|
||||
- Emit `shutdown` before exit; then close sockets.
|
||||
- Methods implemented:
|
||||
- `health`, `status`, `system-presence`, `system-event`, `send`, `agent`.
|
||||
- Optional: `set-heartbeats` removed/renamed if heartbeat concept is retired.
|
||||
- Events implemented:
|
||||
- `agent`, `presence` (deltas, with `stateVersion`), `tick`, `shutdown`.
|
||||
- All events include `seq` for loss/out-of-order detection.
|
||||
- Logging: structured logs on connect/close/error; include client fingerprint.
|
||||
- Slow consumer policy:
|
||||
- Per-connection outbound queue limit (bytes/messages). If exceeded, drop non-critical events (presence/tick) or close with a policy violation / retryable code; clients reconnect with backoff.
|
||||
- Handshake edge cases:
|
||||
- Close on handshake timeout.
|
||||
- Close on over-limit first frame (maxPayload).
|
||||
- Close immediately on non-JSON or wrong `type` before hello.
|
||||
- Default guardrails: `maxPayload` ~512 KB, handshake timeout ~3 s, outbound buffered amount cap ~1.5 MB (tune as you implement).
|
||||
- Dedupe cache: bound TTL (~5m) and max size (~1000 entries); evict oldest first (LRU) to prevent memory growth.
|
||||
|
||||
## Phase 3 — Gateway CLI entrypoint
|
||||
- Add `clawdis gateway` command in CLI program:
|
||||
- Reads config (port, WS options).
|
||||
- Foreground process; exit non-zero on fatal errors.
|
||||
- Flags: `--port`, `--no-tick` (optional), `--log-json` (optional).
|
||||
- System supervision docs for launchd/systemd (see `gateway.md`).
|
||||
|
||||
## Phase 4 — Presence/health snapshot & stateVersion
|
||||
- `hello-ok.snapshot` includes:
|
||||
- `presence[]` (current list)
|
||||
- `health` (full snapshot)
|
||||
- `stateVersion {presence:int, health:int}`
|
||||
- `uptimeMs`
|
||||
- `policy {maxPayload, maxBufferedBytes, tickIntervalMs}`
|
||||
- Emit `presence` deltas with updated `stateVersion.presence`.
|
||||
- Emit `tick` to indicate liveness when no other events occur.
|
||||
- Keep `health` method for manual refresh; not required after connect.
|
||||
- Presence expiry: prune entries older than TTL; enforce a max map size; include `stateVersion` in presence events.
|
||||
|
||||
## Phase 5 — Clients migration
|
||||
- **macOS app**:
|
||||
- Replace stdio/SSH RPC with WS client (tunneled via SSH/Tailscale for remote). ✅ AgentRPC/ControlChannel now use Gateway WS.
|
||||
- Implement handshake, snapshot hydration, subscriptions to `presence`, `tick`, `agent`, `shutdown`. ✅ snapshot + presence events broadcast to InstancesStore; agent events still to wire to UI if desired.
|
||||
- Remove immediate `health/system-presence` fetch on connect. ✅ presence hydrated from snapshot; periodic refresh kept as fallback.
|
||||
- Handle `hello-error` and retry with backoff if version/token mismatched. ✅ macOS GatewayChannel reconnects with exponential backoff.
|
||||
- **CLI**:
|
||||
- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gw:*` commands use the Gateway over WS.
|
||||
- Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.)
|
||||
- **WebChat backend**:
|
||||
- Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via `GatewayClient` in `webchat/server.ts`.
|
||||
- Fail fast if handshake fails; no fallback transports. ✅ (webchat returns gateway unavailable)
|
||||
|
||||
## Phase 6 — Send/agent path hardening
|
||||
- Ensure only the Gateway can open Baileys; no IPC fallback.
|
||||
- `send` executes in-process; respond with explicit result/error, not via heartbeat.
|
||||
- `agent` spawns Tau/Pi; respond quickly with `{runId,status:"accepted"}` (ack); stream `event:agent {runId, seq, stream, data, ts}`; final `res:agent {runId, status:"ok"|"error", summary}` completes request (idempotent via key).
|
||||
- Idempotency: side-effecting methods (`send`, `agent`) accept an idempotency key; keep a short-lived dedupe cache to avoid double-send on client retries. Client retry flow: on timeout/close, retry with same key; Gateway returns cached result when available; cache TTL ~5m and bounded.
|
||||
- Agent stream ordering: enforce monotonic `seq` per runId; if gap detected by server, terminate stream with error; if detected by client, issue a retry with same idempotency key.
|
||||
- Send response shape: `{messageId?, toJid?, error?}` and always include `runId` when available for traceability.
|
||||
|
||||
## Phase 7 — Keepalive and shutdown semantics
|
||||
- Keepalive: `tick` events (or WS ping/pong) at fixed interval; clients treat missing ticks as disconnect and reconnect.
|
||||
- Shutdown: send `event:shutdown {reason, restartExpectedMs?}` then close sockets; clients auto-reconnect.
|
||||
- Restart semantics: close sockets with a standard retryable close code; on reconnect, `hello-ok` snapshot must be sufficient to rebuild UI without event replay.
|
||||
- Use a standard close code (e.g., 1012 service restart or 1001 going away) for planned restart; 1008 policy violation for slow consumers.
|
||||
- Include `policy` in `hello-ok` so clients know the tick interval and buffer limits to tune their expectations.
|
||||
|
||||
## Phase 8 — Cleanup and deprecation
|
||||
- Retire `clawdis rpc` as default path; keep only if explicitly requested (documented as legacy).
|
||||
- Remove reliance on `src/infra/control-channel.ts` for new clients; mark as legacy or delete after migration. ✅ file removed; mac app now uses Gateway WS.
|
||||
- Update README, docs (`architecture.md`, `gateway.md`, `webchat.md`) to final shapes; remove `control-api.md` references if obsolete.
|
||||
- Presence hygiene:
|
||||
- Presence derived primarily from connection (server-fills host/ip/version/connId/instanceId); allow client hints (e.g., lastInputSeconds).
|
||||
- Add TTL/expiry; prune to keep map bounded (e.g., 5m TTL, max 200 entries).
|
||||
|
||||
## Edge cases and ordering
|
||||
- Event ordering: all events carry `seq`; clients detect gaps and should re-fetch snapshot (or targeted refresh) on gap.
|
||||
- Partial handshakes: if client connects and never sends hello, server closes after handshake timeout.
|
||||
- Garbage/oversize first frame: bounded by `maxPayload`; server closes immediately on parse failure.
|
||||
- Duplicate delivery on reconnect: clients must send idempotency keys; Gateway dedupe cache prevents double-send/agent execution.
|
||||
- Snapshot sufficiency: `hello-ok.snapshot` must contain enough to render UI after reconnect without event replay.
|
||||
- Client reconnect guidance: exponential backoff with jitter; reuse same `instanceId` across reconnects to avoid duplicate presence; resend idempotency keys for in-flight sends/agents; on seq gap, issue `health`/`system-presence` refresh.
|
||||
- Presence TTL/defaults: set a concrete TTL (e.g., 5 minutes) and prune periodically; cap the presence map size with LRU if needed.
|
||||
- Replay policy: if seq gap detected, server does not replay; clients must pull fresh `health` + `system-presence` and continue.
|
||||
|
||||
## Phase 9 — Testing & validation
|
||||
- Unit: frame validation, handshake failure, auth/token, stateVersion on presence events, agent stream fanout, send dedupe. ✅
|
||||
- Integration: connect → snapshot → req/res → streaming agent → shutdown. ✅ Covered in gateway WS tests (hello/health/status/presence, agent ack+final, shutdown broadcast).
|
||||
- Load: multiple concurrent WS clients; backpressure behavior under burst. ✅ Basic fanout test with 3 clients receiving presence broadcast; heavier soak still recommended.
|
||||
- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.)
|
||||
- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual.
|
||||
- Idempotency tests: retry send/agent with same key after forced disconnect; expect deduped result. ✅ send + agent dedupe + reconnect retry covered in gateway tests.
|
||||
- Seq-gap handling: ✅ clients now detect seq gaps (GatewayClient + mac GatewayChannel) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional.
|
||||
|
||||
## Phase 10 — Rollout
|
||||
- Version bump; release notes: breaking change to control plane (WS only).
|
||||
- Ship launchd/systemd templates for `clawdis gateway`.
|
||||
- Recommend Tailscale/SSH tunnel for remote access; no additional auth layer assumed in this model.
|
||||
|
||||
---
|
||||
|
||||
- Quick checklist
|
||||
- [x] Protocol types & schemas (TS + JSON Schema + Swift via quicktype)
|
||||
- [x] AJV validators wired
|
||||
- [x] WS server with hello → snapshot → events
|
||||
- [x] Tick + shutdown events
|
||||
- [x] stateVersion + presence deltas
|
||||
- [x] Gateway CLI command
|
||||
- [x] macOS app WS client (Gateway WS for control; presence events live; agent stream UI pending)
|
||||
- [x] WebChat WS client
|
||||
- [x] Remove legacy stdin/TCP paths from default flows (file removed; mac app/CLI on Gateway)
|
||||
- [x] Tests (unit/integration/load) — unit + integration + basic fanout/reconnect; heavier load/soak optional
|
||||
- [x] Docs updated and legacy docs flagged
|
||||
@@ -19,6 +19,8 @@
|
||||
"format:fix": "biome format src --write",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
||||
"protocol:check": "pnpm protocol:gen && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdisProtocol/Protocol.swift",
|
||||
"webchat:bundle": "rolldown -c apps/macos/Sources/Clawdis/Resources/WebChat/rolldown.config.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -33,6 +35,8 @@
|
||||
"@mariozechner/pi-ai": "^0.13.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.13.2",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"@sinclair/typebox": "^0.34.12",
|
||||
"ajv": "^8.17.1",
|
||||
"body-parser": "^2.2.1",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.2",
|
||||
@@ -56,6 +60,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"quicktype-core": "^23.0.48",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
84
scripts/protocol-gen.ts
Normal file
84
scripts/protocol-gen.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProtocolSchemas } from "../src/gateway/protocol/schema.js";
|
||||
import {
|
||||
InputData,
|
||||
JSONSchemaInput,
|
||||
JSONSchemaStore,
|
||||
quicktype,
|
||||
} from "quicktype-core";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
async function writeJsonSchema() {
|
||||
const definitions: Record<string, unknown> = {};
|
||||
for (const [name, schema] of Object.entries(ProtocolSchemas)) {
|
||||
definitions[name] = schema;
|
||||
}
|
||||
|
||||
const rootSchema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://clawdis.dev/protocol.schema.json",
|
||||
title: "Clawdis Gateway Protocol",
|
||||
description: "Handshake, request/response, and event frames for the Gateway WebSocket.",
|
||||
oneOf: [
|
||||
{ $ref: "#/definitions/Hello" },
|
||||
{ $ref: "#/definitions/HelloOk" },
|
||||
{ $ref: "#/definitions/HelloError" },
|
||||
{ $ref: "#/definitions/RequestFrame" },
|
||||
{ $ref: "#/definitions/ResponseFrame" },
|
||||
{ $ref: "#/definitions/EventFrame" },
|
||||
],
|
||||
definitions,
|
||||
};
|
||||
|
||||
const distDir = path.join(repoRoot, "dist");
|
||||
await fs.mkdir(distDir, { recursive: true });
|
||||
const jsonSchemaPath = path.join(distDir, "protocol.schema.json");
|
||||
await fs.writeFile(jsonSchemaPath, JSON.stringify(rootSchema, null, 2));
|
||||
console.log(`wrote ${jsonSchemaPath}`);
|
||||
return { jsonSchemaPath, schemaString: JSON.stringify(rootSchema) };
|
||||
}
|
||||
|
||||
async function writeSwiftModels(schemaString: string) {
|
||||
const schemaInput = new JSONSchemaInput(new JSONSchemaStore());
|
||||
await schemaInput.addSource({ name: "ClawdisGateway", schema: schemaString });
|
||||
|
||||
const inputData = new InputData();
|
||||
inputData.addInput(schemaInput);
|
||||
|
||||
const qtResult = await quicktype({
|
||||
inputData,
|
||||
lang: "swift",
|
||||
topLevel: "GatewayFrame",
|
||||
rendererOptions: {
|
||||
"struct-or-class": "struct",
|
||||
"immutable-types": "true",
|
||||
"accessLevel": "public",
|
||||
},
|
||||
});
|
||||
|
||||
const swiftDir = path.join(
|
||||
repoRoot,
|
||||
"apps",
|
||||
"macos",
|
||||
"Sources",
|
||||
"ClawdisProtocol",
|
||||
);
|
||||
await fs.mkdir(swiftDir, { recursive: true });
|
||||
const swiftPath = path.join(swiftDir, "Protocol.swift");
|
||||
await fs.writeFile(swiftPath, qtResult.lines.join("\n"));
|
||||
console.log(`wrote ${swiftPath}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { schemaString } = await writeJsonSchema();
|
||||
await writeSwiftModels(schemaString);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
// Placeholder suite to keep vitest happy; legacy session reply coverage lives in other files.
|
||||
describe.skip("reply.session (legacy)", () => {
|
||||
it("placeholder", () => {});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@ import { healthCommand } from "../commands/health.js";
|
||||
import { sendCommand } from "../commands/send.js";
|
||||
import { sessionsCommand } from "../commands/sessions.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { startGatewayServer } from "../gateway/server.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { danger, info, setVerbose } from "../globals.js";
|
||||
import { startControlChannel } from "../infra/control-channel.js";
|
||||
import { acquireRelayLock, RelayLockError } from "../infra/relay-lock.js";
|
||||
import { getResolvedLoggerSettings } from "../logging.js";
|
||||
import {
|
||||
@@ -331,6 +332,178 @@ Examples:
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("gateway")
|
||||
.description("Run the WebSocket Gateway (replaces relay)")
|
||||
.option("--port <port>", "Port for the gateway WebSocket", "18789")
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Shared token required in hello.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
const port = Number.parseInt(String(opts.port ?? "18789"), 10);
|
||||
if (Number.isNaN(port) || port <= 0) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (opts.token) {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
||||
}
|
||||
try {
|
||||
await startGatewayServer(port);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
// Keep process alive
|
||||
await new Promise<never>(() => {});
|
||||
});
|
||||
|
||||
const gatewayCallOpts = (cmd: Command) =>
|
||||
cmd
|
||||
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--expect-final", "Wait for final response (agent)" , false);
|
||||
|
||||
gatewayCallOpts(
|
||||
program
|
||||
.command("gw:call")
|
||||
.description("Call a Gateway method over WS and print JSON")
|
||||
.argument("<method>", "Method name (health/status/system-presence/send/agent)")
|
||||
.option("--params <json>", "JSON object string for params", "{}")
|
||||
.action(async (method, opts) => {
|
||||
try {
|
||||
const params = JSON.parse(String(opts.params ?? "{}"));
|
||||
const result = await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method,
|
||||
params,
|
||||
expectFinal: Boolean(opts.expectFinal),
|
||||
timeoutMs: Number(opts.timeout ?? 10000),
|
||||
clientName: "cli",
|
||||
mode: "cli",
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
program
|
||||
.command("gw:health")
|
||||
.description("Fetch Gateway health over WS")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method: "health",
|
||||
timeoutMs: Number(opts.timeout ?? 10000),
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
program
|
||||
.command("gw:status")
|
||||
.description("Fetch Gateway status over WS")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method: "status",
|
||||
timeoutMs: Number(opts.timeout ?? 10000),
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
program
|
||||
.command("gw:send")
|
||||
.description("Send a message via the Gateway")
|
||||
.requiredOption("--to <jidOrPhone>", "Destination (E.164 or jid)")
|
||||
.requiredOption("--message <text>", "Message text")
|
||||
.option("--media-url <url>", "Optional media URL")
|
||||
.option("--idempotency-key <key>", "Idempotency key")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
|
||||
const result = await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method: "send",
|
||||
params: {
|
||||
to: opts.to,
|
||||
message: opts.message,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
idempotencyKey,
|
||||
},
|
||||
timeoutMs: Number(opts.timeout ?? 10000),
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
program
|
||||
.command("gw:agent")
|
||||
.description("Run an agent turn via the Gateway (waits for final)")
|
||||
.requiredOption("--message <text>", "User message")
|
||||
.option("--to <jidOrPhone>", "Destination")
|
||||
.option("--session-id <id>", "Session id")
|
||||
.option("--thinking <level>", "Thinking level")
|
||||
.option("--deliver", "Deliver response", false)
|
||||
.option("--timeout-seconds <n>", "Agent timeout seconds")
|
||||
.option("--idempotency-key <key>", "Idempotency key")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
|
||||
const result = await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method: "agent",
|
||||
params: {
|
||||
message: opts.message,
|
||||
to: opts.to,
|
||||
sessionId: opts.sessionId,
|
||||
thinking: opts.thinking,
|
||||
deliver: Boolean(opts.deliver),
|
||||
timeout: opts.timeoutSeconds
|
||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||
: undefined,
|
||||
idempotencyKey,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: Number(opts.timeout ?? 10000),
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("relay")
|
||||
.description(
|
||||
@@ -508,24 +681,6 @@ Examples:
|
||||
|
||||
const runners: Array<Promise<unknown>> = [];
|
||||
|
||||
let control = null as Awaited<
|
||||
ReturnType<typeof startControlChannel>
|
||||
> | null;
|
||||
try {
|
||||
control = await startControlChannel(
|
||||
{
|
||||
setHeartbeats: async (enabled: boolean) => {
|
||||
setHeartbeatsEnabled(enabled);
|
||||
},
|
||||
},
|
||||
{ runtime: defaultRuntime },
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Control channel failed to start: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
|
||||
if (startWeb) {
|
||||
const webTuning: WebMonitorTuning = {};
|
||||
if (webHeartbeat !== undefined)
|
||||
@@ -613,7 +768,6 @@ Examples:
|
||||
defaultRuntime.exit(1);
|
||||
} finally {
|
||||
if (releaseRelayLock) await releaseRelayLock();
|
||||
if (control) await control.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
70
src/gateway/call.ts
Normal file
70
src/gateway/call.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { GatewayClient } from "./client.js";
|
||||
|
||||
export type CallGatewayOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
expectFinal?: boolean;
|
||||
timeoutMs?: number;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
mode?: string;
|
||||
instanceId?: string;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
};
|
||||
|
||||
export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, value?: T) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(value as T);
|
||||
};
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
instanceId: opts.instanceId ?? randomUUID(),
|
||||
clientName: opts.clientName ?? "cli",
|
||||
clientVersion: opts.clientVersion ?? "dev",
|
||||
platform: opts.platform,
|
||||
mode: opts.mode ?? "cli",
|
||||
minProtocol: opts.minProtocol ?? 1,
|
||||
maxProtocol: opts.maxProtocol ?? 1,
|
||||
onHelloOk: async () => {
|
||||
try {
|
||||
const result = await client.request<T>(opts.method, opts.params, {
|
||||
expectFinal: opts.expectFinal,
|
||||
});
|
||||
client.stop();
|
||||
stop(undefined, result);
|
||||
} catch (err) {
|
||||
client.stop();
|
||||
stop(err as Error);
|
||||
}
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
stop(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
client.stop();
|
||||
stop(new Error("gateway timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
client.start();
|
||||
});
|
||||
}
|
||||
|
||||
export function randomIdempotencyKey() {
|
||||
return randomUUID();
|
||||
}
|
||||
173
src/gateway/client.ts
Normal file
173
src/gateway/client.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import {
|
||||
type EventFrame,
|
||||
type Hello,
|
||||
type HelloOk,
|
||||
type RequestFrame,
|
||||
validateRequestFrame,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: any) => void;
|
||||
reject: (err: Error) => void;
|
||||
expectFinal: boolean;
|
||||
};
|
||||
|
||||
export type GatewayClientOptions = {
|
||||
url?: string; // ws://127.0.0.1:18789
|
||||
token?: string;
|
||||
instanceId?: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
mode?: string;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
onEvent?: (evt: EventFrame) => void;
|
||||
onHelloOk?: (hello: HelloOk) => void;
|
||||
onClose?: (code: number, reason: string) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
};
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private opts: GatewayClientOptions;
|
||||
private pending = new Map<string, Pending>();
|
||||
private backoffMs = 1000;
|
||||
private closed = false;
|
||||
private lastSeq: number | null = null;
|
||||
|
||||
constructor(opts: GatewayClientOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.closed) return;
|
||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||
this.ws = new WebSocket(url, { maxPayload: 512 * 1024 });
|
||||
|
||||
this.ws.on("open", () => this.sendHello());
|
||||
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
||||
this.ws.on("close", (code, reason) => {
|
||||
this.ws = null;
|
||||
this.flushPendingErrors(
|
||||
new Error(`gateway closed (${code}): ${reason.toString()}`),
|
||||
);
|
||||
this.scheduleReconnect();
|
||||
this.opts.onClose?.(code, reason.toString());
|
||||
});
|
||||
this.ws.on("error", (err) => {
|
||||
logDebug(`gateway client error: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.flushPendingErrors(new Error("gateway client stopped"));
|
||||
}
|
||||
|
||||
private sendHello() {
|
||||
const hello: Hello = {
|
||||
type: "hello",
|
||||
minProtocol: this.opts.minProtocol ?? 1,
|
||||
maxProtocol: this.opts.maxProtocol ?? 1,
|
||||
client: {
|
||||
name: this.opts.clientName ?? "webchat-backend",
|
||||
version: this.opts.clientVersion ?? "dev",
|
||||
platform: this.opts.platform ?? process.platform,
|
||||
mode: this.opts.mode ?? "backend",
|
||||
instanceId: this.opts.instanceId,
|
||||
},
|
||||
caps: [],
|
||||
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
||||
};
|
||||
this.ws?.send(JSON.stringify(hello));
|
||||
}
|
||||
|
||||
private handleMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.type === "hello-ok") {
|
||||
this.backoffMs = 1000;
|
||||
this.opts.onHelloOk?.(parsed as HelloOk);
|
||||
return;
|
||||
}
|
||||
if (parsed?.type === "hello-error") {
|
||||
logError(`gateway hello-error: ${parsed.reason}`);
|
||||
this.ws?.close(1008, "hello-error");
|
||||
return;
|
||||
}
|
||||
if (parsed?.type === "event") {
|
||||
const evt = parsed as EventFrame;
|
||||
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
||||
if (seq !== null) {
|
||||
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
||||
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
|
||||
}
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
this.opts.onEvent?.(evt);
|
||||
return;
|
||||
}
|
||||
if (parsed?.type === "res") {
|
||||
const pending = this.pending.get(parsed.id);
|
||||
if (!pending) return;
|
||||
// If the payload is an ack with status accepted, keep waiting for final.
|
||||
const status = parsed.payload?.status;
|
||||
if (pending.expectFinal && status === "accepted") {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(parsed.id);
|
||||
if (parsed.ok) pending.resolve(parsed.payload);
|
||||
else pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug(`gateway client parse error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
|
||||
setTimeout(() => this.start(), delay).unref();
|
||||
}
|
||||
|
||||
private flushPendingErrors(err: Error) {
|
||||
for (const [, p] of this.pending) {
|
||||
p.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: { expectFinal?: boolean },
|
||||
): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("gateway not connected");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const frame: RequestFrame = { type: "req", id, method, params };
|
||||
if (!validateRequestFrame(frame)) {
|
||||
throw new Error(
|
||||
`invalid request frame: ${JSON.stringify(
|
||||
validateRequestFrame.errors,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const expectFinal = opts?.expectFinal === true;
|
||||
const p = new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject, expectFinal });
|
||||
});
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
return p;
|
||||
}
|
||||
}
|
||||
79
src/gateway/protocol/index.ts
Normal file
79
src/gateway/protocol/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import AjvPkg, { type ErrorObject } from "ajv";
|
||||
import {
|
||||
AgentEventSchema,
|
||||
AgentParamsSchema,
|
||||
ErrorCodes,
|
||||
ErrorShapeSchema,
|
||||
EventFrameSchema,
|
||||
HelloErrorSchema,
|
||||
HelloOkSchema,
|
||||
HelloSchema,
|
||||
PresenceEntrySchema,
|
||||
ProtocolSchemas,
|
||||
RequestFrameSchema,
|
||||
ResponseFrameSchema,
|
||||
SendParamsSchema,
|
||||
SnapshotSchema,
|
||||
StateVersionSchema,
|
||||
errorShape,
|
||||
type AgentEvent,
|
||||
type ErrorShape,
|
||||
type EventFrame,
|
||||
type Hello,
|
||||
type HelloError,
|
||||
type HelloOk,
|
||||
type PresenceEntry,
|
||||
type RequestFrame,
|
||||
type ResponseFrame,
|
||||
type Snapshot,
|
||||
type StateVersion,
|
||||
} from "./schema.js";
|
||||
|
||||
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
removeAdditional: false,
|
||||
});
|
||||
|
||||
export const validateHello = ajv.compile<Hello>(HelloSchema);
|
||||
export const validateRequestFrame = ajv.compile<RequestFrame>(RequestFrameSchema);
|
||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
|
||||
if (!errors) return "unknown validation error";
|
||||
return ajv.errorsText(errors, { separator: "; " });
|
||||
}
|
||||
|
||||
export {
|
||||
HelloSchema,
|
||||
HelloOkSchema,
|
||||
HelloErrorSchema,
|
||||
RequestFrameSchema,
|
||||
ResponseFrameSchema,
|
||||
EventFrameSchema,
|
||||
PresenceEntrySchema,
|
||||
SnapshotSchema,
|
||||
ErrorShapeSchema,
|
||||
StateVersionSchema,
|
||||
AgentEventSchema,
|
||||
SendParamsSchema,
|
||||
AgentParamsSchema,
|
||||
ProtocolSchemas,
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
};
|
||||
|
||||
export type {
|
||||
Hello,
|
||||
HelloOk,
|
||||
HelloError,
|
||||
RequestFrame,
|
||||
ResponseFrame,
|
||||
EventFrame,
|
||||
PresenceEntry,
|
||||
Snapshot,
|
||||
ErrorShape,
|
||||
StateVersion,
|
||||
AgentEvent,
|
||||
};
|
||||
239
src/gateway/protocol/schema.ts
Normal file
239
src/gateway/protocol/schema.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Type, type Static, type TSchema } from "@sinclair/typebox";
|
||||
|
||||
const NonEmptyString = Type.String({ minLength: 1 });
|
||||
|
||||
export const PresenceEntrySchema = Type.Object(
|
||||
{
|
||||
host: Type.Optional(NonEmptyString),
|
||||
ip: Type.Optional(NonEmptyString),
|
||||
version: Type.Optional(NonEmptyString),
|
||||
mode: Type.Optional(NonEmptyString),
|
||||
lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
reason: Type.Optional(NonEmptyString),
|
||||
tags: Type.Optional(Type.Array(NonEmptyString)),
|
||||
text: Type.Optional(Type.String()),
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const HealthSnapshotSchema = Type.Any();
|
||||
|
||||
export const StateVersionSchema = Type.Object(
|
||||
{
|
||||
presence: Type.Integer({ minimum: 0 }),
|
||||
health: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SnapshotSchema = Type.Object(
|
||||
{
|
||||
presence: Type.Array(PresenceEntrySchema),
|
||||
health: HealthSnapshotSchema,
|
||||
stateVersion: StateVersionSchema,
|
||||
uptimeMs: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const HelloSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("hello"),
|
||||
minProtocol: Type.Integer({ minimum: 1 }),
|
||||
maxProtocol: Type.Integer({ minimum: 1 }),
|
||||
client: Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
version: NonEmptyString,
|
||||
platform: NonEmptyString,
|
||||
mode: NonEmptyString,
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
||||
auth: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
token: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
locale: Type.Optional(Type.String()),
|
||||
userAgent: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const HelloOkSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("hello-ok"),
|
||||
protocol: Type.Integer({ minimum: 1 }),
|
||||
server: Type.Object(
|
||||
{
|
||||
version: NonEmptyString,
|
||||
commit: Type.Optional(NonEmptyString),
|
||||
host: Type.Optional(NonEmptyString),
|
||||
connId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
features: Type.Object(
|
||||
{
|
||||
methods: Type.Array(NonEmptyString),
|
||||
events: Type.Array(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
snapshot: SnapshotSchema,
|
||||
policy: Type.Object(
|
||||
{
|
||||
maxPayload: Type.Integer({ minimum: 1 }),
|
||||
maxBufferedBytes: Type.Integer({ minimum: 1 }),
|
||||
tickIntervalMs: Type.Integer({ minimum: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const HelloErrorSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("hello-error"),
|
||||
reason: NonEmptyString,
|
||||
expectedProtocol: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
minClient: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ErrorShapeSchema = Type.Object(
|
||||
{
|
||||
code: NonEmptyString,
|
||||
message: NonEmptyString,
|
||||
details: Type.Optional(Type.Unknown()),
|
||||
retryable: Type.Optional(Type.Boolean()),
|
||||
retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const RequestFrameSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("req"),
|
||||
id: NonEmptyString,
|
||||
method: NonEmptyString,
|
||||
params: Type.Optional(Type.Unknown()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ResponseFrameSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("res"),
|
||||
id: NonEmptyString,
|
||||
ok: Type.Boolean(),
|
||||
payload: Type.Optional(Type.Unknown()),
|
||||
error: Type.Optional(ErrorShapeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const EventFrameSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("event"),
|
||||
event: NonEmptyString,
|
||||
payload: Type.Optional(Type.Unknown()),
|
||||
seq: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
stateVersion: Type.Optional(StateVersionSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentEventSchema = Type.Object(
|
||||
{
|
||||
runId: NonEmptyString,
|
||||
seq: Type.Integer({ minimum: 0 }),
|
||||
stream: NonEmptyString,
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
data: Type.Record(Type.String(), Type.Unknown()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SendParamsSchema = Type.Object(
|
||||
{
|
||||
to: NonEmptyString,
|
||||
message: NonEmptyString,
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
provider: Type.Optional(Type.String()),
|
||||
idempotencyKey: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentParamsSchema = Type.Object(
|
||||
{
|
||||
message: NonEmptyString,
|
||||
to: Type.Optional(Type.String()),
|
||||
sessionId: Type.Optional(Type.String()),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
deliver: Type.Optional(Type.Boolean()),
|
||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
idempotencyKey: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
Hello: HelloSchema,
|
||||
HelloOk: HelloOkSchema,
|
||||
HelloError: HelloErrorSchema,
|
||||
RequestFrame: RequestFrameSchema,
|
||||
ResponseFrame: ResponseFrameSchema,
|
||||
EventFrame: EventFrameSchema,
|
||||
PresenceEntry: PresenceEntrySchema,
|
||||
StateVersion: StateVersionSchema,
|
||||
Snapshot: SnapshotSchema,
|
||||
ErrorShape: ErrorShapeSchema,
|
||||
AgentEvent: AgentEventSchema,
|
||||
SendParams: SendParamsSchema,
|
||||
AgentParams: AgentParamsSchema,
|
||||
};
|
||||
|
||||
export type Hello = Static<typeof HelloSchema>;
|
||||
export type HelloOk = Static<typeof HelloOkSchema>;
|
||||
export type HelloError = Static<typeof HelloErrorSchema>;
|
||||
export type RequestFrame = Static<typeof RequestFrameSchema>;
|
||||
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
|
||||
export type EventFrame = Static<typeof EventFrameSchema>;
|
||||
export type Snapshot = Static<typeof SnapshotSchema>;
|
||||
export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||
|
||||
export const ErrorCodes = {
|
||||
NOT_LINKED: "NOT_LINKED",
|
||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||
INVALID_REQUEST: "INVALID_REQUEST",
|
||||
UNAVAILABLE: "UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||
|
||||
export function errorShape(
|
||||
code: ErrorCode,
|
||||
message: string,
|
||||
opts?: { details?: unknown; retryable?: boolean; retryAfterMs?: number },
|
||||
): ErrorShape {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
427
src/gateway/server.test.ts
Normal file
427
src/gateway/server.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { AddressInfo, createServer } from "node:net";
|
||||
import { startGatewayServer } from "./server.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
|
||||
vi.mock("../commands/health.js", () => ({
|
||||
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
||||
}));
|
||||
vi.mock("../commands/status.js", () => ({
|
||||
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
vi.mock("../web/outbound.js", () => ({
|
||||
sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
}));
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
agentCommand: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
server.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 3000) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||
const closeHandler = (code: number, reason: Buffer) => {
|
||||
clearTimeout(timer);
|
||||
ws.off("message", handler);
|
||||
reject(new Error(`closed ${code}: ${reason.toString()}`));
|
||||
};
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const obj = JSON.parse(String(data));
|
||||
if (filter(obj)) {
|
||||
clearTimeout(timer);
|
||||
ws.off("message", handler);
|
||||
ws.off("close", closeHandler);
|
||||
resolve(obj as T);
|
||||
}
|
||||
};
|
||||
ws.on("message", handler);
|
||||
ws.once("close", closeHandler);
|
||||
});
|
||||
}
|
||||
|
||||
async function startServerWithClient(token?: string) {
|
||||
const port = await getFreePort();
|
||||
const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
if (token === undefined) {
|
||||
delete process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = token;
|
||||
}
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
return { server, ws, port, prevToken: prev };
|
||||
}
|
||||
|
||||
describe("gateway server", () => {
|
||||
test("rejects protocol mismatch", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 2,
|
||||
maxProtocol: 3,
|
||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage(ws, () => true);
|
||||
expect(res.type).toBe("hello-error");
|
||||
expect(res.reason).toContain("protocol mismatch");
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
auth: { token: "wrong" },
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage(ws, () => true);
|
||||
expect(res.type).toBe("hello-error");
|
||||
expect(res.reason).toContain("unauthorized");
|
||||
ws.close();
|
||||
await server.close();
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
||||
});
|
||||
|
||||
test("closes silent handshakes after timeout", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closed = await new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 4000);
|
||||
ws.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hello + health + presence + status succeed", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
|
||||
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
|
||||
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "presence1");
|
||||
|
||||
const sendReq = (id: string, method: string) =>
|
||||
ws.send(JSON.stringify({ type: "req", id, method }));
|
||||
sendReq("health1", "health");
|
||||
sendReq("status1", "status");
|
||||
sendReq("presence1", "system-presence");
|
||||
|
||||
const health = await healthP;
|
||||
const status = await statusP;
|
||||
const presence = await presenceP;
|
||||
expect(health.ok).toBe(true);
|
||||
expect(status.ok).toBe(true);
|
||||
expect(presence.ok).toBe(true);
|
||||
expect(Array.isArray(presence.payload)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "evt-1",
|
||||
method: "system-event",
|
||||
params: { text: "note from test" },
|
||||
}),
|
||||
);
|
||||
|
||||
const evt = await presenceEventP;
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
|
||||
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
// Emit a fake agent event directly through the shared emitter.
|
||||
const evtPromise = onceMessage(ws, (o) => o.type === "event" && o.event === "agent");
|
||||
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
|
||||
const evt = await evtPromise;
|
||||
expect(evt.payload.runId).toBe("run-1");
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
expect(evt.payload.data.msg).toBe("hi");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent ack then final response", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
const ackP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted");
|
||||
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag1",
|
||||
method: "agent",
|
||||
params: { message: "hi", idempotencyKey: "idem-ag" },
|
||||
}),
|
||||
);
|
||||
|
||||
const ack = await ackP;
|
||||
const final = await finalP;
|
||||
expect(ack.payload.runId).toBeDefined();
|
||||
expect(final.payload.runId).toBe(ack.payload.runId);
|
||||
expect(final.payload.status).toBe("ok");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent dedupes by idempotencyKey after completion", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
const firstFinalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag1",
|
||||
method: "agent",
|
||||
params: { message: "hi", idempotencyKey: "same-agent" },
|
||||
}),
|
||||
);
|
||||
const firstFinal = await firstFinalP;
|
||||
|
||||
const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag2",
|
||||
method: "agent",
|
||||
params: { message: "hi again", idempotencyKey: "same-agent" },
|
||||
}),
|
||||
);
|
||||
const second = await secondP;
|
||||
expect(second.payload).toEqual(firstFinal.payload);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
const shutdownP = onceMessage(ws, (o) => o.type === "event" && o.event === "shutdown", 5000);
|
||||
await server.close();
|
||||
const evt = await shutdownP;
|
||||
expect(evt.payload?.reason).toBeDefined();
|
||||
});
|
||||
|
||||
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const mkClient = async () => {
|
||||
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => c.once("open", resolve));
|
||||
c.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(c, (o) => o.type === "hello-ok");
|
||||
return c;
|
||||
};
|
||||
|
||||
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
|
||||
const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"));
|
||||
clients[0].send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "broadcast",
|
||||
method: "system-event",
|
||||
params: { text: "fanout" },
|
||||
}),
|
||||
);
|
||||
const events = await Promise.all(waits);
|
||||
for (const evt of events) {
|
||||
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
}
|
||||
for (const c of clients) c.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
|
||||
const idem = "same-key";
|
||||
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
||||
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
|
||||
const sendReq = (id: string) =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "send",
|
||||
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
|
||||
}),
|
||||
);
|
||||
sendReq("a1");
|
||||
sendReq("a2");
|
||||
|
||||
const res1 = await res1P;
|
||||
const res2 = await res2P;
|
||||
expect(res1.ok).toBe(true);
|
||||
expect(res2.ok).toBe(true);
|
||||
expect(res1.payload).toEqual(res2.payload);
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
|
||||
const dial = async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
return ws;
|
||||
};
|
||||
|
||||
const idem = "reconnect-agent";
|
||||
const ws1 = await dial();
|
||||
const final1P = onceMessage(ws1, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", 6000);
|
||||
ws1.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag1",
|
||||
method: "agent",
|
||||
params: { message: "hi", idempotencyKey: idem },
|
||||
}),
|
||||
);
|
||||
const final1 = await final1P;
|
||||
ws1.close();
|
||||
|
||||
const ws2 = await dial();
|
||||
const final2P = onceMessage(ws2, (o) => o.type === "res" && o.id === "ag2", 6000);
|
||||
ws2.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "ag2",
|
||||
method: "agent",
|
||||
params: { message: "hi again", idempotencyKey: idem },
|
||||
}),
|
||||
);
|
||||
const res = await final2P;
|
||||
expect(res.payload).toEqual(final1.payload);
|
||||
ws2.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
479
src/gateway/server.ts
Normal file
479
src/gateway/server.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import os from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
|
||||
import { getHealthSnapshot } from "../commands/health.js";
|
||||
import { getStatusSummary } from "../commands/status.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
type Hello,
|
||||
type RequestFrame,
|
||||
type Snapshot,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateAgentParams,
|
||||
validateHello,
|
||||
validateRequestFrame,
|
||||
validateSendParams,
|
||||
} from "./protocol/index.js";
|
||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||
import { createDefaultDeps } from "../cli/deps.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
|
||||
type Client = {
|
||||
socket: WebSocket;
|
||||
hello: Hello;
|
||||
connId: string;
|
||||
};
|
||||
|
||||
const METHODS = [
|
||||
"health",
|
||||
"status",
|
||||
"system-presence",
|
||||
"system-event",
|
||||
"set-heartbeats",
|
||||
"send",
|
||||
"agent",
|
||||
];
|
||||
|
||||
const EVENTS = ["agent", "presence", "tick", "shutdown"];
|
||||
|
||||
export type GatewayServer = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
let presenceVersion = 1;
|
||||
let healthVersion = 1;
|
||||
let seq = 0;
|
||||
|
||||
function buildSnapshot(): Snapshot {
|
||||
const presence = listSystemPresence();
|
||||
const uptimeMs = Math.round(process.uptime() * 1000);
|
||||
// Health is async; caller should await getHealthSnapshot and replace later if needed.
|
||||
const emptyHealth: unknown = {};
|
||||
return {
|
||||
presence,
|
||||
health: emptyHealth,
|
||||
stateVersion: { presence: presenceVersion, health: healthVersion },
|
||||
uptimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
||||
const HANDSHAKE_TIMEOUT_MS = 3000;
|
||||
const TICK_INTERVAL_MS = 30_000;
|
||||
const DEDUPE_TTL_MS = 5 * 60_000;
|
||||
const DEDUPE_MAX = 1000;
|
||||
const SERVER_PROTO = 1;
|
||||
|
||||
type DedupeEntry = { ts: number; ok: boolean; payload?: unknown; error?: ErrorShape };
|
||||
const dedupe = new Map<string, DedupeEntry>();
|
||||
|
||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
|
||||
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
const wss = new WebSocketServer({ port, host: "127.0.0.1", maxPayload: MAX_PAYLOAD_BYTES });
|
||||
const clients = new Set<Client>();
|
||||
|
||||
const broadcast = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: { dropIfSlow?: boolean; stateVersion?: { presence?: number; health?: number } },
|
||||
) => {
|
||||
const frame = JSON.stringify({
|
||||
type: "event",
|
||||
event,
|
||||
payload,
|
||||
seq: ++seq,
|
||||
stateVersion: opts?.stateVersion,
|
||||
});
|
||||
for (const c of clients) {
|
||||
const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
|
||||
if (slow && opts?.dropIfSlow) continue;
|
||||
if (slow) {
|
||||
try {
|
||||
c.socket.close(1008, "slow consumer");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
c.socket.send(frame);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// periodic keepalive
|
||||
const tickInterval = setInterval(() => {
|
||||
broadcast("tick", { ts: Date.now() }, { dropIfSlow: true });
|
||||
}, TICK_INTERVAL_MS);
|
||||
|
||||
// dedupe cache cleanup
|
||||
const dedupeCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of dedupe) {
|
||||
if (now - v.ts > DEDUPE_TTL_MS) dedupe.delete(k);
|
||||
}
|
||||
if (dedupe.size > DEDUPE_MAX) {
|
||||
const entries = [...dedupe.entries()].sort((a, b) => a[1].ts - b[1].ts);
|
||||
for (let i = 0; i < dedupe.size - DEDUPE_MAX; i++) {
|
||||
dedupe.delete(entries[i][0]);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const agentUnsub = onAgentEvent((evt) => {
|
||||
broadcast("agent", evt);
|
||||
});
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
let client: Client | null = null;
|
||||
let closed = false;
|
||||
const connId = randomUUID();
|
||||
const deps = createDefaultDeps();
|
||||
|
||||
const send = (obj: unknown) => {
|
||||
try {
|
||||
socket.send(JSON.stringify(obj));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearTimeout(handshakeTimer);
|
||||
if (client) clients.delete(client);
|
||||
try {
|
||||
socket.close(1000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
socket.once("error", () => close());
|
||||
socket.once("close", () => {
|
||||
if (client) {
|
||||
// mark presence as disconnected
|
||||
const key = client.hello.client.instanceId || connId;
|
||||
upsertPresence(key, {
|
||||
reason: "disconnect",
|
||||
});
|
||||
presenceVersion += 1;
|
||||
broadcast(
|
||||
"presence",
|
||||
{ presence: listSystemPresence() },
|
||||
{
|
||||
dropIfSlow: true,
|
||||
stateVersion: { presence: presenceVersion, health: healthVersion },
|
||||
},
|
||||
);
|
||||
}
|
||||
close();
|
||||
});
|
||||
|
||||
const handshakeTimer = setTimeout(() => {
|
||||
if (!client) close();
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
socket.on("message", async (data) => {
|
||||
if (closed) return;
|
||||
const text = data.toString();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!client) {
|
||||
// Expect hello
|
||||
if (!validateHello(parsed)) {
|
||||
send({
|
||||
type: "hello-error",
|
||||
reason: `invalid hello: ${formatValidationErrors(validateHello.errors)}`,
|
||||
});
|
||||
socket.close(1008, "invalid hello");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const hello = parsed as Hello;
|
||||
// protocol negotiation
|
||||
const { minProtocol, maxProtocol } = hello;
|
||||
if (maxProtocol < SERVER_PROTO || minProtocol > SERVER_PROTO) {
|
||||
send({
|
||||
type: "hello-error",
|
||||
reason: "protocol mismatch",
|
||||
expectedProtocol: SERVER_PROTO,
|
||||
});
|
||||
socket.close(1002, "protocol mismatch");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// token auth if required
|
||||
const token = getGatewayToken();
|
||||
if (token && hello.auth?.token !== token) {
|
||||
send({
|
||||
type: "hello-error",
|
||||
reason: "unauthorized",
|
||||
});
|
||||
socket.close(1008, "unauthorized");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
client = { socket, hello, connId };
|
||||
clients.add(client);
|
||||
// synthesize presence entry for this connection
|
||||
const presenceKey = hello.client.instanceId || connId;
|
||||
upsertPresence(presenceKey, {
|
||||
host: os.hostname(),
|
||||
version:
|
||||
process.env.CLAWDIS_VERSION ??
|
||||
process.env.npm_package_version ??
|
||||
"dev",
|
||||
mode: hello.client.mode,
|
||||
instanceId: hello.client.instanceId,
|
||||
reason: "connect",
|
||||
});
|
||||
presenceVersion += 1;
|
||||
const snapshot = buildSnapshot();
|
||||
// Fill health asynchronously for snapshot
|
||||
const health = await getHealthSnapshot();
|
||||
snapshot.health = health;
|
||||
snapshot.stateVersion.health = ++healthVersion;
|
||||
const helloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: SERVER_PROTO,
|
||||
server: {
|
||||
version: process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "dev",
|
||||
commit: process.env.GIT_COMMIT,
|
||||
host: os.hostname(),
|
||||
connId,
|
||||
},
|
||||
features: { methods: METHODS, events: EVENTS },
|
||||
snapshot,
|
||||
policy: {
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||
tickIntervalMs: TICK_INTERVAL_MS,
|
||||
},
|
||||
};
|
||||
clearTimeout(handshakeTimer);
|
||||
send(helloOk);
|
||||
return;
|
||||
}
|
||||
|
||||
// After handshake, accept only req frames
|
||||
if (!validateRequestFrame(parsed)) {
|
||||
send({
|
||||
type: "res",
|
||||
id: (parsed as { id?: unknown })?.id ?? "invalid",
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid request frame: ${formatValidationErrors(validateRequestFrame.errors)}`,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const req = parsed as RequestFrame;
|
||||
const respond = (
|
||||
ok: boolean,
|
||||
payload?: unknown,
|
||||
error?: ErrorShape,
|
||||
) => send({ type: "res", id: req.id, ok, payload, error });
|
||||
|
||||
switch (req.method) {
|
||||
case "health": {
|
||||
const health = await getHealthSnapshot();
|
||||
healthVersion += 1;
|
||||
respond(true, health, undefined);
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
const status = await getStatusSummary();
|
||||
respond(true, status, undefined);
|
||||
break;
|
||||
}
|
||||
case "system-presence": {
|
||||
const presence = listSystemPresence();
|
||||
respond(true, presence, undefined);
|
||||
break;
|
||||
}
|
||||
case "system-event": {
|
||||
const text = String((req.params as { text?: unknown } | undefined)?.text ?? "").trim();
|
||||
if (!text) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
|
||||
break;
|
||||
}
|
||||
enqueueSystemEvent(text);
|
||||
presenceVersion += 1;
|
||||
broadcast(
|
||||
"presence",
|
||||
{ presence: listSystemPresence() },
|
||||
{
|
||||
dropIfSlow: true,
|
||||
stateVersion: { presence: presenceVersion, health: healthVersion },
|
||||
},
|
||||
);
|
||||
respond(true, { ok: true }, undefined);
|
||||
break;
|
||||
}
|
||||
case "set-heartbeats": {
|
||||
respond(true, { ok: true }, undefined);
|
||||
break;
|
||||
}
|
||||
case "send": {
|
||||
const p = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateSendParams(p)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid send params: ${formatValidationErrors(validateSendParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const params = p as {
|
||||
to: string;
|
||||
message: string;
|
||||
mediaUrl?: string;
|
||||
provider?: string;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
const idem = params.idempotencyKey;
|
||||
const cached = dedupe.get(`send:${idem}`);
|
||||
if (cached) {
|
||||
respond(cached.ok, cached.payload, cached.error);
|
||||
break;
|
||||
}
|
||||
const to = params.to.trim();
|
||||
const message = params.message.trim();
|
||||
try {
|
||||
const result = await sendMessageWhatsApp(to, message, {
|
||||
mediaUrl: params.mediaUrl,
|
||||
verbose: false,
|
||||
});
|
||||
const payload = {
|
||||
runId: idem,
|
||||
messageId: result.messageId,
|
||||
toJid: result.toJid ?? `${to}@s.whatsapp.net`,
|
||||
};
|
||||
dedupe.set(`send:${idem}`, { ts: Date.now(), ok: true, payload });
|
||||
respond(true, payload, undefined);
|
||||
} catch (err) {
|
||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||
dedupe.set(`send:${idem}`, { ts: Date.now(), ok: false, error });
|
||||
respond(false, undefined, error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent": {
|
||||
const p = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateAgentParams(p)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid agent params: ${formatValidationErrors(validateAgentParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const params = p as {
|
||||
message: string;
|
||||
to?: string;
|
||||
sessionId?: string;
|
||||
thinking?: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
timeout?: number;
|
||||
};
|
||||
const idem = params.idempotencyKey;
|
||||
const cached = dedupe.get(`agent:${idem}`);
|
||||
if (cached) {
|
||||
respond(cached.ok, cached.payload, cached.error);
|
||||
break;
|
||||
}
|
||||
const message = params.message.trim();
|
||||
const runId = params.sessionId || randomUUID();
|
||||
const ackPayload = { runId, status: "accepted" as const };
|
||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: ackPayload });
|
||||
respond(true, ackPayload, undefined); // ack quickly
|
||||
try {
|
||||
await agentCommand(
|
||||
{
|
||||
message,
|
||||
to: params.to,
|
||||
sessionId: params.sessionId,
|
||||
thinking: params.thinking,
|
||||
deliver: params.deliver,
|
||||
timeout: params.timeout?.toString(),
|
||||
},
|
||||
defaultRuntime,
|
||||
deps,
|
||||
);
|
||||
const payload = { runId, status: "ok" as const, summary: "completed" };
|
||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload });
|
||||
respond(true, payload, undefined);
|
||||
} catch (err) {
|
||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||
const payload = { runId, status: "error" as const, summary: String(err) };
|
||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: false, payload, error });
|
||||
respond(false, payload, error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`gateway: parse/handle error: ${String(err)}`);
|
||||
// If still in handshake, close; otherwise respond error
|
||||
if (!client) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defaultRuntime.log(
|
||||
`gateway listening on ws://127.0.0.1:${port} (PID ${process.pid})`,
|
||||
);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
broadcast("shutdown", { reason: "gateway stopping", restartExpectedMs: null });
|
||||
clearInterval(tickInterval);
|
||||
clearInterval(dedupeCleanup);
|
||||
if (agentUnsub) {
|
||||
try {
|
||||
agentUnsub();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const c of clients) {
|
||||
try {
|
||||
c.socket.close(1012, "service restart");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
clients.clear();
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { startControlChannel } from "./control-channel.js";
|
||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||
|
||||
// Mock health/status to avoid hitting real services
|
||||
vi.mock("../commands/health.js", () => ({
|
||||
getHealthSnapshot: vi.fn(async () => ({
|
||||
ts: Date.now(),
|
||||
durationMs: 10,
|
||||
web: {
|
||||
linked: true,
|
||||
authAgeMs: 1000,
|
||||
connect: { ok: true, status: 200, error: null, elapsedMs: 5 },
|
||||
},
|
||||
heartbeatSeconds: 60,
|
||||
sessions: { path: "/tmp/sessions.json", count: 1, recent: [] },
|
||||
ipc: { path: "/tmp/clawdis.sock", exists: true },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/status.js", () => ({
|
||||
getStatusSummary: vi.fn(async () => ({
|
||||
web: { linked: true, authAgeMs: 1000 },
|
||||
heartbeatSeconds: 60,
|
||||
sessions: {
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: { model: "claude-opus-4-5", contextTokens: 200_000 },
|
||||
recent: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("control channel", () => {
|
||||
let server: Awaited<ReturnType<typeof startControlChannel>>;
|
||||
let client: net.Socket;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startControlChannel({}, { port: 19999 });
|
||||
client = net.createConnection({ host: "127.0.0.1", port: 19999 });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
client.destroy();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const sendRequest = (method: string, params?: unknown) =>
|
||||
new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
const frame = { type: "request", id, method, params };
|
||||
client.write(`${JSON.stringify(frame)}\n`);
|
||||
const onData = (chunk: Buffer) => {
|
||||
const lines = chunk.toString("utf8").trim().split(/\n/);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { id?: string };
|
||||
if (parsed.id === id) {
|
||||
client.off("data", onData);
|
||||
resolve(parsed as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore non-JSON noise */
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on("data", onData);
|
||||
client.on("error", reject);
|
||||
});
|
||||
|
||||
it("responds to ping", async () => {
|
||||
const res = await sendRequest("ping");
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns health snapshot", async () => {
|
||||
const res = await sendRequest("health");
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as { web?: { linked?: boolean } };
|
||||
expect(payload.web?.linked).toBe(true);
|
||||
});
|
||||
|
||||
it("emits heartbeat events", async () => {
|
||||
const evtPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
const handler = (chunk: Buffer) => {
|
||||
const lines = chunk.toString("utf8").trim().split(/\n/);
|
||||
for (const line of lines) {
|
||||
const parsed = JSON.parse(line) as { type?: string; event?: string };
|
||||
if (parsed.type === "event" && parsed.event === "heartbeat") {
|
||||
client.off("data", handler);
|
||||
resolve(parsed as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on("data", handler);
|
||||
});
|
||||
|
||||
emitHeartbeatEvent({ status: "sent", to: "+1", preview: "hi" });
|
||||
const evt = await evtPromise;
|
||||
expect(evt.event).toBe("heartbeat");
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import net from "node:net";
|
||||
|
||||
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
|
||||
import { getStatusSummary, type StatusSummary } from "../commands/status.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { type AgentEventPayload, onAgentEvent } from "./agent-events.js";
|
||||
import {
|
||||
emitHeartbeatEvent,
|
||||
getLastHeartbeatEvent,
|
||||
type HeartbeatEventPayload,
|
||||
onHeartbeatEvent,
|
||||
} from "./heartbeat-events.js";
|
||||
import { enqueueSystemEvent } from "./system-events.js";
|
||||
import { listSystemPresence, updateSystemPresence } from "./system-presence.js";
|
||||
|
||||
type ControlRequest = {
|
||||
type: "request";
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ControlResponse = {
|
||||
type: "response";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ControlEvent = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type Handlers = {
|
||||
setHeartbeats?: (enabled: boolean) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type ControlServer = {
|
||||
close: () => Promise<void>;
|
||||
broadcastHeartbeat: (evt: HeartbeatEventPayload) => void;
|
||||
broadcastAgentEvent: (evt: AgentEventPayload) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_PORT = 18789;
|
||||
|
||||
export async function startControlChannel(
|
||||
handlers: Handlers = {},
|
||||
opts: { port?: number; runtime?: RuntimeEnv } = {},
|
||||
): Promise<ControlServer> {
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
|
||||
const clients = new Set<net.Socket>();
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
socket.setEncoding("utf8");
|
||||
clients.add(socket);
|
||||
|
||||
// Seed relay status + last heartbeat for new clients.
|
||||
write(socket, {
|
||||
type: "event",
|
||||
event: "relay-status",
|
||||
payload: { state: "running" },
|
||||
});
|
||||
const last = getLastHeartbeatEvent();
|
||||
if (last)
|
||||
write(socket, { type: "event", event: "heartbeat", payload: last });
|
||||
|
||||
let buffer = "";
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
logDebug(`control: line ${line.slice(0, 200)}`);
|
||||
handleLine(socket, line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
/* ignore */
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
clients.delete(socket);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const stopHeartbeat = onHeartbeatEvent((evt) => broadcast("heartbeat", evt));
|
||||
const stopAgent = onAgentEvent((evt) => broadcast("agent", evt));
|
||||
|
||||
const handleLine = async (socket: net.Socket, line: string) => {
|
||||
if (!line) return;
|
||||
const started = Date.now();
|
||||
let parsed: ControlRequest;
|
||||
try {
|
||||
parsed = JSON.parse(line) as ControlRequest;
|
||||
} catch (err) {
|
||||
logError(
|
||||
`control: parse error (${String(err)}) on line: ${line.slice(0, 200)}`,
|
||||
);
|
||||
return write(socket, {
|
||||
type: "response",
|
||||
id: "",
|
||||
ok: false,
|
||||
error: `parse error: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.type !== "request" || !parsed.id) {
|
||||
return write(socket, {
|
||||
type: "response",
|
||||
id: parsed.id ?? "",
|
||||
ok: false,
|
||||
error: "unsupported frame",
|
||||
});
|
||||
}
|
||||
|
||||
const respond = (payload: unknown, ok = true, error?: string) =>
|
||||
write(socket, {
|
||||
type: "response",
|
||||
id: parsed.id,
|
||||
ok,
|
||||
payload: ok ? payload : undefined,
|
||||
error: ok ? undefined : error,
|
||||
});
|
||||
|
||||
try {
|
||||
logDebug(`control: recv ${parsed.method}`);
|
||||
switch (parsed.method) {
|
||||
case "ping": {
|
||||
respond({ pong: true, ts: Date.now() });
|
||||
break;
|
||||
}
|
||||
case "health": {
|
||||
const summary = await getHealthSnapshot();
|
||||
respond(summary satisfies HealthSummary);
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
const summary = await getStatusSummary();
|
||||
respond(summary satisfies StatusSummary);
|
||||
break;
|
||||
}
|
||||
case "last-heartbeat": {
|
||||
respond(getLastHeartbeatEvent());
|
||||
break;
|
||||
}
|
||||
case "set-heartbeats": {
|
||||
const enabled = Boolean(parsed.params?.enabled);
|
||||
if (handlers.setHeartbeats) await handlers.setHeartbeats(enabled);
|
||||
respond({ ok: true });
|
||||
break;
|
||||
}
|
||||
case "system-event": {
|
||||
const text = String(parsed.params?.text ?? "").trim();
|
||||
if (text) {
|
||||
enqueueSystemEvent(text);
|
||||
updateSystemPresence(text);
|
||||
}
|
||||
respond({ ok: true });
|
||||
break;
|
||||
}
|
||||
case "system-presence": {
|
||||
const pres = listSystemPresence();
|
||||
logDebug?.(`control: system-presence count=${pres.length}`);
|
||||
respond(pres);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
respond(undefined, false, `unknown method: ${parsed.method}`);
|
||||
break;
|
||||
}
|
||||
logDebug(
|
||||
`control: ${parsed.method} responded in ${Date.now() - started}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
logError(
|
||||
`control: ${parsed.method} failed in ${Date.now() - started}ms: ${String(err)}`,
|
||||
);
|
||||
respond(undefined, false, String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const write = (socket: net.Socket, frame: ControlResponse | ControlEvent) => {
|
||||
try {
|
||||
socket.write(`${JSON.stringify(frame)}\n`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const broadcast = (event: string, payload: unknown) => {
|
||||
const frame: ControlEvent = { type: "event", event, payload };
|
||||
const line = `${JSON.stringify(frame)}\n`;
|
||||
for (const client of [...clients]) {
|
||||
try {
|
||||
client.write(line);
|
||||
} catch {
|
||||
clients.delete(client);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runtime.log?.(`control channel listening on 127.0.0.1:${port}`);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
stopHeartbeat();
|
||||
stopAgent();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
for (const client of [...clients]) {
|
||||
client.destroy();
|
||||
}
|
||||
clients.clear();
|
||||
},
|
||||
broadcastHeartbeat: (evt: HeartbeatEventPayload) => {
|
||||
emitHeartbeatEvent(evt);
|
||||
broadcast("heartbeat", evt);
|
||||
},
|
||||
broadcastAgentEvent: (evt: AgentEventPayload) => {
|
||||
broadcast("agent", evt);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,11 +7,14 @@ export type SystemPresence = {
|
||||
lastInputSeconds?: number;
|
||||
mode?: string;
|
||||
reason?: string;
|
||||
instanceId?: string;
|
||||
text: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
const entries = new Map<string, SystemPresence>();
|
||||
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
function resolvePrimaryIPv4(): string | undefined {
|
||||
const nets = os.networkInterfaces();
|
||||
@@ -36,12 +39,12 @@ function initSelfPresence() {
|
||||
const ip = resolvePrimaryIPv4() ?? undefined;
|
||||
const version =
|
||||
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown";
|
||||
const text = `Relay: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode relay · reason self`;
|
||||
const text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`;
|
||||
const selfEntry: SystemPresence = {
|
||||
host,
|
||||
ip,
|
||||
version,
|
||||
mode: "relay",
|
||||
mode: "gateway",
|
||||
reason: "self",
|
||||
text,
|
||||
ts: Date.now(),
|
||||
@@ -105,8 +108,41 @@ export function updateSystemPresence(text: string) {
|
||||
entries.set(key, parsed);
|
||||
}
|
||||
|
||||
export function upsertPresence(
|
||||
key: string,
|
||||
presence: Partial<SystemPresence>,
|
||||
) {
|
||||
ensureSelfPresence();
|
||||
const existing = entries.get(key) ?? ({} as SystemPresence);
|
||||
const merged: SystemPresence = {
|
||||
...existing,
|
||||
...presence,
|
||||
ts: Date.now(),
|
||||
text:
|
||||
presence.text ||
|
||||
existing.text ||
|
||||
`Node: ${presence.host ?? existing.host ?? "unknown"} · mode ${
|
||||
presence.mode ?? existing.mode ?? "unknown"
|
||||
}`,
|
||||
};
|
||||
entries.set(key, merged);
|
||||
}
|
||||
|
||||
export function listSystemPresence(): SystemPresence[] {
|
||||
ensureSelfPresence();
|
||||
// prune expired
|
||||
const now = Date.now();
|
||||
for (const [k, v] of [...entries]) {
|
||||
if (now - v.ts > TTL_MS) entries.delete(k);
|
||||
}
|
||||
// enforce max size (LRU by ts)
|
||||
if (entries.size > MAX_ENTRIES) {
|
||||
const sorted = [...entries.entries()].sort((a, b) => a[1].ts - b[1].ts);
|
||||
const toDrop = entries.size - MAX_ENTRIES;
|
||||
for (let i = 0; i < toDrop; i++) {
|
||||
entries.delete(sorted[i][0]);
|
||||
}
|
||||
}
|
||||
touchSelfPresence();
|
||||
return [...entries.values()].sort((a, b) => b.ts - a.ts);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user