Gateway: finalize WS control plane
This commit is contained in:
parent
9ef1545d06
commit
b2e7fb01a9
23 changed files with 5209 additions and 2495 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
import Darwin
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
|
|
@ -9,73 +8,52 @@ struct ControlRequestParams: @unchecked Sendable {
|
||||||
actor AgentRPC {
|
actor AgentRPC {
|
||||||
static let shared = 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 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(
|
func send(
|
||||||
text: String,
|
text: String,
|
||||||
|
|
@ -84,305 +62,25 @@ actor AgentRPC {
|
||||||
deliver: Bool,
|
deliver: Bool,
|
||||||
to: String?) async -> (ok: Bool, text: String?, error: String?)
|
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 {
|
do {
|
||||||
var payload: [String: Any] = [
|
let params: [String: AnyHashable] = [
|
||||||
"type": "send",
|
"message": AnyHashable(text),
|
||||||
"text": text,
|
"sessionId": AnyHashable(session),
|
||||||
"session": session,
|
"thinking": AnyHashable(thinking ?? "default"),
|
||||||
"thinking": thinking ?? "default",
|
"deliver": AnyHashable(deliver),
|
||||||
"deliver": deliver,
|
"to": AnyHashable(to ?? ""),
|
||||||
|
"idempotencyKey": AnyHashable(UUID().uuidString),
|
||||||
]
|
]
|
||||||
if let to { payload["to"] = to }
|
_ = try await controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
||||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
return (true, nil, nil)
|
||||||
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)")
|
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("rpc send failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
await self.stop()
|
|
||||||
return (false, nil, error.localizedDescription)
|
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 {
|
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
||||||
if self.process?.isRunning != true {
|
try await start()
|
||||||
try await self.start()
|
let rawParams = params?.raw.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
||||||
}
|
return try await gateway.request(method: method, params: rawParams)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,20 +77,26 @@ final class ControlChannel: ObservableObject {
|
||||||
case degraded(String)
|
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 state: ConnectionState = .disconnected
|
||||||
@Published private(set) var lastPingMs: Double?
|
@Published private(set) var lastPingMs: Double?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
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 {
|
func configure() async {
|
||||||
do {
|
do {
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
try await AgentRPC.shared.start()
|
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
||||||
|
self.startEventStream()
|
||||||
self.state = .connected
|
self.state = .connected
|
||||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -98,16 +104,16 @@ final class ControlChannel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(mode: Mode) async throws {
|
func configure(mode _: Any? = nil) async throws { await self.configure() }
|
||||||
// Mode is retained for API compatibility; transport is always stdio now.
|
|
||||||
await self.configure()
|
|
||||||
}
|
|
||||||
|
|
||||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
let params = timeout.map { ControlRequestParams(raw: ["timeoutMs": AnyHashable(Int($0 * 1000))]) }
|
|
||||||
do {
|
do {
|
||||||
let start = Date()
|
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
|
let ms = Date().timeIntervalSince(start) * 1000
|
||||||
self.lastPingMs = ms
|
self.lastPingMs = ms
|
||||||
self.state = .connected
|
self.state = .connected
|
||||||
|
|
@ -119,14 +125,14 @@ final class ControlChannel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||||
let data = try await AgentRPC.shared.controlRequest(method: "last-heartbeat")
|
// Heartbeat removed in new protocol
|
||||||
if data.isEmpty { return nil }
|
return nil
|
||||||
return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
|
||||||
do {
|
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
|
self.state = .connected
|
||||||
return data
|
return data
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -136,9 +142,48 @@ final class ControlChannel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String) async throws {
|
func sendSystemEvent(_ text: String) async throws {
|
||||||
_ = try await self.request(
|
_ = try await self.request(method: "system-event", params: ["text": AnyHashable(text)])
|
||||||
method: "system-event",
|
}
|
||||||
params: ControlRequestParams(raw: ["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 let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
private let interval: TimeInterval = 30
|
private let interval: TimeInterval = 30
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
guard self.task == nil else { return }
|
||||||
|
self.observeGatewayEvents()
|
||||||
self.task = Task.detached { [weak self] in
|
self.task = Task.detached { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.refresh()
|
await self.refresh()
|
||||||
|
|
@ -52,6 +54,45 @@ final class InstancesStore: ObservableObject {
|
||||||
func stop() {
|
func stop() {
|
||||||
self.task?.cancel()
|
self.task?.cancel()
|
||||||
self.task = nil
|
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 {
|
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)
|
RelayProcessManager.shared.setActive(!state.isPaused)
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
let controlMode: ControlChannel.Mode = AppStateStore.shared.connectionMode == .remote
|
try? await ControlChannel.shared.configure()
|
||||||
? .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)
|
|
||||||
PresenceReporter.shared.start()
|
PresenceReporter.shared.start()
|
||||||
}
|
}
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
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",
|
"format:fix": "biome format src --write",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"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"
|
"webchat:bundle": "rolldown -c apps/macos/Sources/Clawdis/Resources/WebChat/rolldown.config.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|
@ -33,6 +35,8 @@
|
||||||
"@mariozechner/pi-ai": "^0.13.2",
|
"@mariozechner/pi-ai": "^0.13.2",
|
||||||
"@mariozechner/pi-coding-agent": "^0.13.2",
|
"@mariozechner/pi-coding-agent": "^0.13.2",
|
||||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||||
|
"@sinclair/typebox": "^0.34.12",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
|
|
@ -56,6 +60,7 @@
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/qrcode-terminal": "^0.12.2",
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
|
"quicktype-core": "^23.0.48",
|
||||||
"@vitest/coverage-v8": "^4.0.15",
|
"@vitest/coverage-v8": "^4.0.15",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"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 { sendCommand } from "../commands/send.js";
|
||||||
import { sessionsCommand } from "../commands/sessions.js";
|
import { sessionsCommand } from "../commands/sessions.js";
|
||||||
import { statusCommand } from "../commands/status.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 { loadConfig } from "../config/config.js";
|
||||||
import { danger, info, setVerbose } from "../globals.js";
|
import { danger, info, setVerbose } from "../globals.js";
|
||||||
import { startControlChannel } from "../infra/control-channel.js";
|
|
||||||
import { acquireRelayLock, RelayLockError } from "../infra/relay-lock.js";
|
import { acquireRelayLock, RelayLockError } from "../infra/relay-lock.js";
|
||||||
import { getResolvedLoggerSettings } from "../logging.js";
|
import { getResolvedLoggerSettings } from "../logging.js";
|
||||||
import {
|
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
|
program
|
||||||
.command("relay")
|
.command("relay")
|
||||||
.description(
|
.description(
|
||||||
|
|
@ -508,24 +681,6 @@ Examples:
|
||||||
|
|
||||||
const runners: Array<Promise<unknown>> = [];
|
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) {
|
if (startWeb) {
|
||||||
const webTuning: WebMonitorTuning = {};
|
const webTuning: WebMonitorTuning = {};
|
||||||
if (webHeartbeat !== undefined)
|
if (webHeartbeat !== undefined)
|
||||||
|
|
@ -613,7 +768,6 @@ Examples:
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
if (releaseRelayLock) await releaseRelayLock();
|
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;
|
lastInputSeconds?: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
instanceId?: string;
|
||||||
text: string;
|
text: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const entries = new Map<string, SystemPresence>();
|
const entries = new Map<string, SystemPresence>();
|
||||||
|
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const MAX_ENTRIES = 200;
|
||||||
|
|
||||||
function resolvePrimaryIPv4(): string | undefined {
|
function resolvePrimaryIPv4(): string | undefined {
|
||||||
const nets = os.networkInterfaces();
|
const nets = os.networkInterfaces();
|
||||||
|
|
@ -36,12 +39,12 @@ function initSelfPresence() {
|
||||||
const ip = resolvePrimaryIPv4() ?? undefined;
|
const ip = resolvePrimaryIPv4() ?? undefined;
|
||||||
const version =
|
const version =
|
||||||
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown";
|
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 = {
|
const selfEntry: SystemPresence = {
|
||||||
host,
|
host,
|
||||||
ip,
|
ip,
|
||||||
version,
|
version,
|
||||||
mode: "relay",
|
mode: "gateway",
|
||||||
reason: "self",
|
reason: "self",
|
||||||
text,
|
text,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
|
|
@ -105,8 +108,41 @@ export function updateSystemPresence(text: string) {
|
||||||
entries.set(key, parsed);
|
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[] {
|
export function listSystemPresence(): SystemPresence[] {
|
||||||
ensureSelfPresence();
|
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();
|
touchSelfPresence();
|
||||||
return [...entries.values()].sort((a, b) => b.ts - a.ts);
|
return [...entries.values()].sort((a, b) => b.ts - a.ts);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue