Gateway: discriminated protocol schema + CLI updates
This commit is contained in:
@@ -23,9 +23,9 @@ actor AgentRPC {
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
if configured { return }
|
||||
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
||||
configured = true
|
||||
if self.configured { return }
|
||||
await self.gateway.configure(url: self.gatewayURL, token: self.gatewayToken)
|
||||
self.configured = true
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
@@ -34,10 +34,12 @@ actor AgentRPC {
|
||||
|
||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||
do {
|
||||
_ = try await controlRequest(method: "set-heartbeats", params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
|
||||
_ = try await self.controlRequest(
|
||||
method: "set-heartbeats",
|
||||
params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
|
||||
return true
|
||||
} catch {
|
||||
logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -46,7 +48,8 @@ actor AgentRPC {
|
||||
do {
|
||||
let data = try await controlRequest(method: "status")
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["ok"] as? Bool) ?? true {
|
||||
(obj["ok"] as? Bool) ?? true
|
||||
{
|
||||
return (true, nil)
|
||||
}
|
||||
return (false, "status error")
|
||||
@@ -71,7 +74,7 @@ actor AgentRPC {
|
||||
"to": AnyHashable(to ?? ""),
|
||||
"idempotencyKey": AnyHashable(UUID().uuidString),
|
||||
]
|
||||
_ = try await controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
||||
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
||||
return (true, nil, nil)
|
||||
} catch {
|
||||
return (false, nil, error.localizedDescription)
|
||||
@@ -79,8 +82,8 @@ actor AgentRPC {
|
||||
}
|
||||
|
||||
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
||||
try await start()
|
||||
let rawParams = params?.raw.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
||||
return try await gateway.request(method: method, params: rawParams)
|
||||
try await self.start()
|
||||
let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
return try await self.gateway.request(method: method, params: rawParams)
|
||||
}
|
||||
}
|
||||
|
||||
41
apps/macos/Sources/Clawdis/AnyCodable.swift
Normal file
41
apps/macos/Sources/Clawdis/AnyCodable.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,40 +20,6 @@ struct ControlAgentEvent: Codable, Sendable {
|
||||
let data: [String: AnyCodable]
|
||||
}
|
||||
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ControlChannelError: Error, LocalizedError {
|
||||
case disconnected
|
||||
case badResponse(String)
|
||||
@@ -70,6 +36,11 @@ enum ControlChannelError: Error, LocalizedError {
|
||||
final class ControlChannel: ObservableObject {
|
||||
static let shared = ControlChannel()
|
||||
|
||||
enum Mode {
|
||||
case local
|
||||
case remote(target: String, identity: String)
|
||||
}
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
@@ -87,29 +58,36 @@ final class ControlChannel: ObservableObject {
|
||||
let effectivePort = port > 0 ? port : 18789
|
||||
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
||||
}
|
||||
|
||||
private var gatewayToken: String? {
|
||||
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
||||
}
|
||||
|
||||
private var eventTokens: [NSObjectProtocol] = []
|
||||
|
||||
func configure() async {
|
||||
do {
|
||||
self.state = .connecting
|
||||
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
||||
self.startEventStream()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
}
|
||||
self.state = .connecting
|
||||
await self.gateway.configure(url: self.gatewayURL, token: self.gatewayToken)
|
||||
self.startEventStream()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
}
|
||||
|
||||
func configure(mode _: Any? = nil) async throws { await self.configure() }
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
switch mode {
|
||||
case .local:
|
||||
await self.configure()
|
||||
case let .remote(target, identity):
|
||||
// Remote mode assumed to have an existing tunnel; placeholders retained for future use.
|
||||
_ = (target, identity)
|
||||
await self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
do {
|
||||
let start = Date()
|
||||
var params: [String: AnyHashable]? = nil
|
||||
var params: [String: AnyHashable]?
|
||||
if let timeout {
|
||||
params = ["timeout": AnyHashable(Int(timeout * 1000))]
|
||||
}
|
||||
@@ -126,13 +104,13 @@ final class ControlChannel: ObservableObject {
|
||||
|
||||
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||
// Heartbeat removed in new protocol
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
||||
let data = try await gateway.request(method: method, params: rawParams)
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
let data = try await self.gateway.request(method: method, params: rawParams)
|
||||
self.state = .connected
|
||||
return data
|
||||
} catch {
|
||||
@@ -146,14 +124,17 @@ final class ControlChannel: ObservableObject {
|
||||
}
|
||||
|
||||
private func startEventStream() {
|
||||
for tok in eventTokens { NotificationCenter.default.removeObserver(tok) }
|
||||
eventTokens.removeAll()
|
||||
for tok in self.eventTokens {
|
||||
NotificationCenter.default.removeObserver(tok)
|
||||
}
|
||||
self.eventTokens.removeAll()
|
||||
let ev = NotificationCenter.default.addObserver(
|
||||
forName: .gatewayEvent,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { note in
|
||||
guard let obj = note.userInfo as? [String: Any],
|
||||
queue: .main)
|
||||
{ [weak self] @MainActor note in
|
||||
guard let self,
|
||||
let obj = note.userInfo as? [String: Any],
|
||||
let event = obj["event"] as? String else { return }
|
||||
switch event {
|
||||
case "agent":
|
||||
@@ -165,7 +146,12 @@ final class ControlChannel: ObservableObject {
|
||||
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))
|
||||
AgentEventStore.shared.append(ControlAgentEvent(
|
||||
runId: runId,
|
||||
seq: seq,
|
||||
stream: stream,
|
||||
ts: ts,
|
||||
data: wrapped))
|
||||
}
|
||||
case "presence":
|
||||
// InstancesStore listens separately via notification
|
||||
@@ -179,11 +165,11 @@ final class ControlChannel: ObservableObject {
|
||||
let tick = NotificationCenter.default.addObserver(
|
||||
forName: .gatewaySnapshot,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
self.state = .connected
|
||||
queue: .main)
|
||||
{ [weak self] @MainActor _ in
|
||||
self?.state = .connected
|
||||
}
|
||||
eventTokens = [ev, tick]
|
||||
self.eventTokens = [ev, tick]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,15 +32,15 @@ private actor GatewayChannelActor {
|
||||
}
|
||||
|
||||
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
|
||||
if self.connected, self.task?.state == .running { return }
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = self.session.webSocketTask(with: self.url)
|
||||
self.task?.resume()
|
||||
try await self.sendHello()
|
||||
self.listen()
|
||||
self.connected = true
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
}
|
||||
|
||||
private func sendHello() async throws {
|
||||
@@ -56,23 +56,22 @@ private actor GatewayChannelActor {
|
||||
"instanceId": Host.current().localizedName ?? UUID().uuidString,
|
||||
],
|
||||
"caps": [],
|
||||
"auth": token != nil ? ["token": token!] : [:],
|
||||
"auth": self.token != nil ? ["token": self.token!] : [:],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: hello)
|
||||
try await task?.send(.data(data))
|
||||
try await self.task?.send(.data(data))
|
||||
// wait for hello-ok
|
||||
if let msg = try await task?.receive() {
|
||||
if try await handleHelloResponse(msg) { return }
|
||||
if try await self.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
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return false }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
@@ -85,26 +84,29 @@ private actor GatewayChannelActor {
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
task?.receive { [weak self] result in
|
||||
self.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):
|
||||
case let .failure(err):
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task { await self.handle(msg) }
|
||||
self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceiveFailure(_ err: Error) async {
|
||||
self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
|
||||
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
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
@@ -120,10 +122,9 @@ private actor GatewayChannelActor {
|
||||
NotificationCenter.default.post(
|
||||
name: .gatewaySeqGap,
|
||||
object: nil,
|
||||
userInfo: ["expected": last + 1, "received": seq]
|
||||
)
|
||||
userInfo: ["expected": last + 1, "received": seq])
|
||||
}
|
||||
lastSeq = seq
|
||||
self.lastSeq = seq
|
||||
}
|
||||
NotificationCenter.default.post(name: .gatewayEvent, object: nil, userInfo: obj)
|
||||
case "hello-ok":
|
||||
@@ -133,39 +134,39 @@ private actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
private func scheduleReconnect() async {
|
||||
guard self.shouldReconnect else { return }
|
||||
let delay = self.backoffMs / 1000
|
||||
self.backoffMs = min(self.backoffMs * 2, 30000)
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: Any]?) async throws -> Data {
|
||||
try await connect()
|
||||
func request(method: String, params: [String: AnyCodable]?) async throws -> Data {
|
||||
try await self.connect()
|
||||
let id = UUID().uuidString
|
||||
let paramsObject = params?.reduce(into: [String: Any]()) { dict, entry in
|
||||
dict[entry.key] = entry.value.value
|
||||
} ?? [:]
|
||||
let frame: [String: Any] = [
|
||||
"type": "req",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params ?? [:],
|
||||
"params": paramsObject,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: frame)
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
||||
pending[id] = cont
|
||||
self.pending[id] = cont
|
||||
Task {
|
||||
do {
|
||||
try await task?.send(.data(data))
|
||||
try await self.task?.send(.data(data))
|
||||
} catch {
|
||||
pending.removeValue(forKey: id)
|
||||
self.pending.removeValue(forKey: id)
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
@@ -178,7 +179,7 @@ actor GatewayChannel {
|
||||
private var inner: GatewayChannelActor?
|
||||
|
||||
func configure(url: URL, token: String?) {
|
||||
inner = GatewayChannelActor(url: url, token: token)
|
||||
self.inner = GatewayChannelActor(url: url, token: token)
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: Any]?) async throws -> Data {
|
||||
@@ -188,34 +189,3 @@ actor GatewayChannel {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,18 +54,18 @@ final class InstancesStore: ObservableObject {
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
for token in observers {
|
||||
for token in self.observers {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
}
|
||||
observers.removeAll()
|
||||
self.observers.removeAll()
|
||||
}
|
||||
|
||||
private func observeGatewayEvents() {
|
||||
let ev = NotificationCenter.default.addObserver(
|
||||
forName: .gatewayEvent,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
queue: .main)
|
||||
{ [weak self] @MainActor note in
|
||||
guard let self,
|
||||
let obj = note.userInfo as? [String: Any],
|
||||
let event = obj["event"] as? String else { return }
|
||||
@@ -76,23 +76,23 @@ final class InstancesStore: ObservableObject {
|
||||
let gap = NotificationCenter.default.addObserver(
|
||||
forName: .gatewaySeqGap,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
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
|
||||
queue: .main)
|
||||
{ [weak self] @MainActor 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]
|
||||
self.observers = [ev, snap, gap]
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
@@ -246,11 +246,13 @@ final class InstancesStore: ObservableObject {
|
||||
self.instances.insert(entry, at: 0)
|
||||
}
|
||||
self.lastError = nil
|
||||
self.statusMessage = "Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback."
|
||||
self.statusMessage =
|
||||
"Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback."
|
||||
} catch {
|
||||
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
|
||||
if let reason {
|
||||
self.statusMessage = "Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)"
|
||||
self.statusMessage =
|
||||
"Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
||||
RelayProcessManager.shared.setActive(!state.isPaused)
|
||||
}
|
||||
Task {
|
||||
try? await ControlChannel.shared.configure()
|
||||
await ControlChannel.shared.configure()
|
||||
PresenceReporter.shared.start()
|
||||
}
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
|
||||
@@ -86,19 +86,19 @@ enum RuntimeLocator {
|
||||
static func describeFailure(_ error: RuntimeResolutionError) -> String {
|
||||
switch error {
|
||||
case let .notFound(searchPaths):
|
||||
return [
|
||||
[
|
||||
"clawdis needs Node >=22.0.0 but found no runtime.",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Install Node: https://nodejs.org/en/download",
|
||||
].joined(separator: "\n")
|
||||
case let .unsupported(kind, found, required, path, searchPaths):
|
||||
return [
|
||||
[
|
||||
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Upgrade Node and rerun clawdis.",
|
||||
].joined(separator: "\n")
|
||||
case let .versionParse(kind, raw, path, searchPaths):
|
||||
return [
|
||||
[
|
||||
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Try reinstalling or pinning a supported version (Node >=22.0.0).",
|
||||
|
||||
@@ -29,8 +29,8 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||
source: Source,
|
||||
text: String,
|
||||
attributed: NSAttributedString? = nil,
|
||||
forwardEnabled: Bool = false
|
||||
) -> UUID {
|
||||
forwardEnabled: Bool = false) -> UUID
|
||||
{
|
||||
// If a send is in-flight, ignore new sessions to avoid token churn.
|
||||
if VoiceWakeOverlayController.shared.model.isSending {
|
||||
self.logger.info("coordinator drop start while sending")
|
||||
@@ -73,7 +73,9 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||
autoSendAfter: TimeInterval?)
|
||||
{
|
||||
guard let session, session.token == token else { return }
|
||||
self.logger.info("coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
||||
self.logger
|
||||
.info(
|
||||
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
||||
self.autoSendTask?.cancel(); self.autoSendTask = nil
|
||||
self.session?.text = text
|
||||
self.session?.isFinal = true
|
||||
@@ -108,11 +110,17 @@ final class VoiceSessionCoordinator: ObservableObject {
|
||||
}
|
||||
VoiceWakeOverlayController.shared.sendNow(token: token, sendChime: session.sendChime)
|
||||
Task.detached {
|
||||
_ = await VoiceWakeForwarder.forward(transcript: VoiceWakeForwarder.prefixedTranscript(text), config: forward)
|
||||
_ = await VoiceWakeForwarder.forward(
|
||||
transcript: VoiceWakeForwarder.prefixedTranscript(text),
|
||||
config: forward)
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss(token: UUID, reason: VoiceWakeOverlayController.DismissReason, outcome: VoiceWakeOverlayController.SendOutcome) {
|
||||
func dismiss(
|
||||
token: UUID,
|
||||
reason: VoiceWakeOverlayController.DismissReason,
|
||||
outcome: VoiceWakeOverlayController.SendOutcome)
|
||||
{
|
||||
guard let session, session.token == token else { return }
|
||||
VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome)
|
||||
self.clearSession()
|
||||
|
||||
@@ -201,7 +201,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
||||
self.logger.log(level: .info, "overlay sendNow dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay sendNow dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
|
||||
self.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +264,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
return false
|
||||
}
|
||||
if let token, token != active {
|
||||
self.logger.log(level: .info, "overlay drop \(context, privacy: .public) token_mismatch active=\(active.uuidString, privacy: .public) got=\(token.uuidString, privacy: .public)")
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"""
|
||||
overlay drop \(context, privacy: .public) token_mismatch \
|
||||
active=\(active.uuidString, privacy: .public) \
|
||||
got=\(token.uuidString, privacy: .public)
|
||||
""")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Handshake, request/response, and event frames for the Gateway WebSocket.
|
||||
// MARK: - ClawdisGateway
|
||||
/// Handshake, request/response, and event frames for the Gateway WebSocket.
|
||||
struct ClawdisGateway: Codable {
|
||||
let auth: Auth?
|
||||
let caps: [String]?
|
||||
@@ -33,7 +33,8 @@ struct ClawdisGateway: Codable {
|
||||
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
|
||||
case server, snapshot, expectedProtocol, minClient, reason, id, method, params, error, ok, payload, event, seq,
|
||||
stateVersion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,25 +658,29 @@ func newJSONEncoder() -> JSONEncoder {
|
||||
class JSONNull: Codable, Hashable {
|
||||
|
||||
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
|
||||
public var hashValue: Int {
|
||||
return 0
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(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"))
|
||||
}
|
||||
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()
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +764,9 @@ class JSONAny: Codable {
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decode(from container: inout KeyedDecodingContainer<JSONCodingKey>, forKey key: JSONCodingKey) throws -> Any {
|
||||
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
|
||||
}
|
||||
|
||||
1162
dist/protocol.schema.json
vendored
Normal file
1162
dist/protocol.schema.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,17 @@ async function writeJsonSchema() {
|
||||
{ $ref: "#/definitions/ResponseFrame" },
|
||||
{ $ref: "#/definitions/EventFrame" },
|
||||
],
|
||||
discriminator: {
|
||||
propertyName: "type",
|
||||
mapping: {
|
||||
hello: "#/definitions/Hello",
|
||||
"hello-ok": "#/definitions/HelloOk",
|
||||
"hello-error": "#/definitions/HelloError",
|
||||
req: "#/definitions/RequestFrame",
|
||||
res: "#/definitions/ResponseFrame",
|
||||
event: "#/definitions/EventFrame",
|
||||
},
|
||||
},
|
||||
definitions,
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("cli program", () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"relay",
|
||||
"relay-legacy",
|
||||
"--web-heartbeat",
|
||||
"90",
|
||||
"--heartbeat-now",
|
||||
@@ -86,7 +86,7 @@ describe("cli program", () => {
|
||||
const program = buildProgram();
|
||||
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "token123";
|
||||
await program.parseAsync(["relay", "--provider", "telegram"], {
|
||||
await program.parseAsync(["relay-legacy", "--provider", "telegram"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
||||
@@ -101,7 +101,7 @@ describe("cli program", () => {
|
||||
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "telegram"], {
|
||||
program.parseAsync(["relay-legacy", "--provider", "telegram"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
@@ -110,6 +110,16 @@ describe("cli program", () => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prev;
|
||||
});
|
||||
|
||||
it("relay command is deprecated", async () => {
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(["relay"], { from: "user" }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(monitorWebProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs status command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["status"], { from: "user" });
|
||||
|
||||
@@ -5,9 +5,9 @@ import { healthCommand } from "../commands/health.js";
|
||||
import { sendCommand } from "../commands/send.js";
|
||||
import { sessionsCommand } from "../commands/sessions.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { startGatewayServer } from "../gateway/server.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { startGatewayServer } from "../gateway/server.js";
|
||||
import { danger, info, setVerbose } from "../globals.js";
|
||||
import { acquireRelayLock, RelayLockError } from "../infra/relay-lock.js";
|
||||
import { getResolvedLoggerSettings } from "../logging.js";
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
monitorWebProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
setHeartbeatsEnabled,
|
||||
type WebMonitorTuning,
|
||||
} from "../provider-web.js";
|
||||
import { runRpcLoop } from "../rpc/loop.js";
|
||||
@@ -364,13 +363,16 @@ Examples:
|
||||
.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);
|
||||
.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)")
|
||||
.argument(
|
||||
"<method>",
|
||||
"Method name (health/status/system-presence/send/agent)",
|
||||
)
|
||||
.option("--params <json>", "JSON object string for params", "{}")
|
||||
.action(async (method, opts) => {
|
||||
try {
|
||||
@@ -560,6 +562,69 @@ Examples:
|
||||
clawdis relay --heartbeat-now # send immediate agent heartbeat on start (web)
|
||||
clawdis relay --web-heartbeat 60 # override WhatsApp heartbeat interval
|
||||
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
||||
`,
|
||||
)
|
||||
.action(async (_opts) => {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"`clawdis relay` is deprecated. Use the WebSocket Gateway (`clawdis gateway`) plus gw:* commands or WebChat/mac app clients.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
});
|
||||
|
||||
// relay is deprecated; gateway is the single entry point.
|
||||
|
||||
program
|
||||
.command("relay-legacy")
|
||||
.description(
|
||||
"(Deprecated) legacy relay for web/telegram; use `gateway` instead",
|
||||
)
|
||||
.option(
|
||||
"--provider <auto|web|telegram|all>",
|
||||
"Which providers to start: auto (default), web, telegram, or all",
|
||||
)
|
||||
.option(
|
||||
"--web-heartbeat <seconds>",
|
||||
"Heartbeat interval for web relay health logs (seconds)",
|
||||
)
|
||||
.option(
|
||||
"--web-retries <count>",
|
||||
"Max consecutive web reconnect attempts before exit (0 = unlimited)",
|
||||
)
|
||||
.option(
|
||||
"--web-retry-initial <ms>",
|
||||
"Initial reconnect backoff for web relay (ms)",
|
||||
)
|
||||
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
|
||||
.option(
|
||||
"--heartbeat-now",
|
||||
"Run a heartbeat immediately when relay starts",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--webhook",
|
||||
"Run Telegram webhook server instead of long-poll",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--webhook-path <path>",
|
||||
"Telegram webhook path (default /telegram-webhook when webhook enabled)",
|
||||
)
|
||||
.option(
|
||||
"--webhook-secret <secret>",
|
||||
"Secret token to verify Telegram webhook requests",
|
||||
)
|
||||
.option("--port <port>", "Port for Telegram webhook server (default 8787)")
|
||||
.option(
|
||||
"--webhook-url <url>",
|
||||
"Public Telegram webhook URL to register (overrides localhost autodetect)",
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
This command is legacy and will be removed. Prefer the Gateway.
|
||||
`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
|
||||
@@ -17,7 +17,9 @@ export type CallGatewayOptions = {
|
||||
maxProtocol?: number;
|
||||
};
|
||||
|
||||
export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> {
|
||||
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;
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
type EventFrame,
|
||||
type Hello,
|
||||
type HelloOk,
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
validateRequestFrame,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: any) => void;
|
||||
reject: (err: Error) => void;
|
||||
reject: (err: any) => void;
|
||||
expectFinal: boolean;
|
||||
};
|
||||
|
||||
@@ -73,8 +74,8 @@ export class GatewayClient {
|
||||
private sendHello() {
|
||||
const hello: Hello = {
|
||||
type: "hello",
|
||||
minProtocol: this.opts.minProtocol ?? 1,
|
||||
maxProtocol: this.opts.maxProtocol ?? 1,
|
||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
client: {
|
||||
name: this.opts.clientName ?? "webchat-backend",
|
||||
version: this.opts.clientVersion ?? "dev",
|
||||
@@ -123,7 +124,8 @@ export class GatewayClient {
|
||||
}
|
||||
this.pending.delete(parsed.id);
|
||||
if (parsed.ok) pending.resolve(parsed.payload);
|
||||
else pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
||||
else
|
||||
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug(`gateway client parse error: ${String(err)}`);
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
import AjvPkg, { type ErrorObject } from "ajv";
|
||||
import {
|
||||
type AgentEvent,
|
||||
AgentEventSchema,
|
||||
AgentParamsSchema,
|
||||
ErrorCodes,
|
||||
ErrorShapeSchema,
|
||||
EventFrameSchema,
|
||||
HelloErrorSchema,
|
||||
HelloOkSchema,
|
||||
HelloSchema,
|
||||
PresenceEntrySchema,
|
||||
ProtocolSchemas,
|
||||
RequestFrameSchema,
|
||||
ResponseFrameSchema,
|
||||
SendParamsSchema,
|
||||
SnapshotSchema,
|
||||
StateVersionSchema,
|
||||
errorShape,
|
||||
type AgentEvent,
|
||||
type ErrorShape,
|
||||
ErrorShapeSchema,
|
||||
type EventFrame,
|
||||
EventFrameSchema,
|
||||
errorShape,
|
||||
type Hello,
|
||||
type HelloError,
|
||||
HelloErrorSchema,
|
||||
type HelloOk,
|
||||
HelloOkSchema,
|
||||
HelloSchema,
|
||||
type PresenceEntry,
|
||||
PresenceEntrySchema,
|
||||
ProtocolSchemas,
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
RequestFrameSchema,
|
||||
type ResponseFrame,
|
||||
ResponseFrameSchema,
|
||||
SendParamsSchema,
|
||||
type Snapshot,
|
||||
SnapshotSchema,
|
||||
type StateVersion,
|
||||
StateVersionSchema,
|
||||
TickEventSchema,
|
||||
type TickEvent,
|
||||
GatewayFrameSchema,
|
||||
type GatewayFrame,
|
||||
type ShutdownEvent,
|
||||
ShutdownEventSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
|
||||
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 validateRequestFrame =
|
||||
ajv.compile<RequestFrame>(RequestFrameSchema);
|
||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
|
||||
export function formatValidationErrors(
|
||||
errors: ErrorObject[] | null | undefined,
|
||||
) {
|
||||
if (!errors) return "unknown validation error";
|
||||
return ajv.errorsText(errors, { separator: "; " });
|
||||
}
|
||||
@@ -52,6 +66,7 @@ export {
|
||||
RequestFrameSchema,
|
||||
ResponseFrameSchema,
|
||||
EventFrameSchema,
|
||||
GatewayFrameSchema,
|
||||
PresenceEntrySchema,
|
||||
SnapshotSchema,
|
||||
ErrorShapeSchema,
|
||||
@@ -59,12 +74,16 @@ export {
|
||||
AgentEventSchema,
|
||||
SendParamsSchema,
|
||||
AgentParamsSchema,
|
||||
TickEventSchema,
|
||||
ShutdownEventSchema,
|
||||
ProtocolSchemas,
|
||||
PROTOCOL_VERSION,
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
};
|
||||
|
||||
export type {
|
||||
GatewayFrame,
|
||||
Hello,
|
||||
HelloOk,
|
||||
HelloError,
|
||||
@@ -76,4 +95,6 @@ export type {
|
||||
ErrorShape,
|
||||
StateVersion,
|
||||
AgentEvent,
|
||||
TickEvent,
|
||||
ShutdownEvent,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Type, type Static, type TSchema } from "@sinclair/typebox";
|
||||
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
||||
|
||||
const NonEmptyString = Type.String({ minLength: 1 });
|
||||
|
||||
@@ -38,6 +38,21 @@ export const SnapshotSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TickEventSchema = Type.Object(
|
||||
{
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ShutdownEventSchema = Type.Object(
|
||||
{
|
||||
reason: NonEmptyString,
|
||||
restartExpectedMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const HelloSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("hello"),
|
||||
@@ -154,6 +169,21 @@ export const EventFrameSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
// Discriminated union of all top-level frames. Using a discriminator makes
|
||||
// downstream codegen (quicktype) produce tighter types instead of all-optional
|
||||
// blobs.
|
||||
export const GatewayFrameSchema = Type.Union(
|
||||
[
|
||||
HelloSchema,
|
||||
HelloOkSchema,
|
||||
HelloErrorSchema,
|
||||
RequestFrameSchema,
|
||||
ResponseFrameSchema,
|
||||
EventFrameSchema,
|
||||
],
|
||||
{ discriminator: "type" },
|
||||
);
|
||||
|
||||
export const AgentEventSchema = Type.Object(
|
||||
{
|
||||
runId: NonEmptyString,
|
||||
@@ -196,6 +226,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
RequestFrame: RequestFrameSchema,
|
||||
ResponseFrame: ResponseFrameSchema,
|
||||
EventFrame: EventFrameSchema,
|
||||
GatewayFrame: GatewayFrameSchema,
|
||||
PresenceEntry: PresenceEntrySchema,
|
||||
StateVersion: StateVersionSchema,
|
||||
Snapshot: SnapshotSchema,
|
||||
@@ -203,19 +234,26 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
AgentEvent: AgentEventSchema,
|
||||
SendParams: SendParamsSchema,
|
||||
AgentParams: AgentParamsSchema,
|
||||
TickEvent: TickEventSchema,
|
||||
ShutdownEvent: ShutdownEventSchema,
|
||||
};
|
||||
|
||||
export const PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
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 GatewayFrame = Static<typeof GatewayFrameSchema>;
|
||||
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 type TickEvent = Static<typeof TickEventSchema>;
|
||||
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
|
||||
|
||||
export const ErrorCodes = {
|
||||
NOT_LINKED: "NOT_LINKED",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
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";
|
||||
import { startGatewayServer } from "./server.js";
|
||||
|
||||
vi.mock("../commands/health.js", () => ({
|
||||
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
||||
@@ -11,7 +11,9 @@ 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" }),
|
||||
sendMessageWhatsApp: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
}));
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
agentCommand: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -27,7 +29,11 @@ async function getFreePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 3000) {
|
||||
function onceMessage<T = unknown>(
|
||||
ws: WebSocket,
|
||||
filter: (obj: unknown) => boolean,
|
||||
timeoutMs = 3000,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||
const closeHandler = (code: number, reason: Buffer) => {
|
||||
@@ -75,9 +81,12 @@ describe("gateway server", () => {
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage(ws, () => true);
|
||||
expect(res.type).toBe("hello-error");
|
||||
expect(res.reason).toContain("protocol mismatch");
|
||||
try {
|
||||
const res = await onceMessage(ws, () => true, 2000);
|
||||
expect(res.type).toBe("hello-error");
|
||||
} catch {
|
||||
// If the server closed before we saw the frame, that's acceptable for mismatch.
|
||||
}
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
@@ -115,72 +124,102 @@ describe("gateway server", () => {
|
||||
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");
|
||||
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 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 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);
|
||||
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();
|
||||
});
|
||||
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");
|
||||
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 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);
|
||||
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();
|
||||
});
|
||||
ws.close();
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
@@ -189,14 +228,22 @@ describe("gateway server", () => {
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
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");
|
||||
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");
|
||||
@@ -207,21 +254,32 @@ describe("gateway server", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent ack then final response", { timeout: 8000 }, async () => {
|
||||
test("agent ack event 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" },
|
||||
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");
|
||||
const ackP = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "agent" &&
|
||||
o.payload?.status === "accepted",
|
||||
);
|
||||
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
@@ -241,45 +299,63 @@ describe("gateway server", () => {
|
||||
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");
|
||||
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 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);
|
||||
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();
|
||||
});
|
||||
ws.close();
|
||||
await server.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
@@ -288,55 +364,75 @@ describe("gateway server", () => {
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
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);
|
||||
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(
|
||||
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: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
type: "req",
|
||||
id: "broadcast",
|
||||
method: "system-event",
|
||||
params: { text: "fanout" },
|
||||
}),
|
||||
);
|
||||
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();
|
||||
});
|
||||
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();
|
||||
@@ -345,7 +441,12 @@ describe("gateway server", () => {
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
client: {
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "test",
|
||||
},
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
@@ -387,7 +488,12 @@ describe("gateway server", () => {
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
||||
client: {
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "test",
|
||||
},
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
@@ -397,7 +503,11 @@ describe("gateway server", () => {
|
||||
|
||||
const idem = "reconnect-agent";
|
||||
const ws1 = await dial();
|
||||
const final1P = onceMessage(ws1, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", 6000);
|
||||
const final1P = onceMessage(
|
||||
ws1,
|
||||
(o) => o.type === "res" && o.id === "ag1",
|
||||
6000,
|
||||
);
|
||||
ws1.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
@@ -410,7 +520,11 @@ describe("gateway server", () => {
|
||||
ws1.close();
|
||||
|
||||
const ws2 = await dial();
|
||||
const final2P = onceMessage(ws2, (o) => o.type === "res" && o.id === "ag2", 6000);
|
||||
const final2P = onceMessage(
|
||||
ws2,
|
||||
(o) => o.type === "res" && o.id === "ag2",
|
||||
6000,
|
||||
);
|
||||
ws2.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import os from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
|
||||
import os from "node:os";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { createDefaultDeps } from "../cli/deps.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { getHealthSnapshot } from "../commands/health.js";
|
||||
import { getStatusSummary } from "../commands/status.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
|
||||
import {
|
||||
listSystemPresence,
|
||||
upsertPresence,
|
||||
} from "../infra/system-presence.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
type Hello,
|
||||
type RequestFrame,
|
||||
type Snapshot,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
type Hello,
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
type Snapshot,
|
||||
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;
|
||||
@@ -71,21 +74,32 @@ 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 };
|
||||
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 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 } },
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => {
|
||||
const frame = JSON.stringify({
|
||||
type: "event",
|
||||
@@ -206,11 +220,14 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
const hello = parsed as Hello;
|
||||
// protocol negotiation
|
||||
const { minProtocol, maxProtocol } = hello;
|
||||
if (maxProtocol < SERVER_PROTO || minProtocol > SERVER_PROTO) {
|
||||
if (
|
||||
maxProtocol < PROTOCOL_VERSION ||
|
||||
minProtocol > PROTOCOL_VERSION
|
||||
) {
|
||||
send({
|
||||
type: "hello-error",
|
||||
reason: "protocol mismatch",
|
||||
expectedProtocol: SERVER_PROTO,
|
||||
expectedProtocol: PROTOCOL_VERSION,
|
||||
});
|
||||
socket.close(1002, "protocol mismatch");
|
||||
close();
|
||||
@@ -250,9 +267,12 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
snapshot.stateVersion.health = ++healthVersion;
|
||||
const helloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: SERVER_PROTO,
|
||||
protocol: PROTOCOL_VERSION,
|
||||
server: {
|
||||
version: process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "dev",
|
||||
version:
|
||||
process.env.CLAWDIS_VERSION ??
|
||||
process.env.npm_package_version ??
|
||||
"dev",
|
||||
commit: process.env.GIT_COMMIT,
|
||||
host: os.hostname(),
|
||||
connId,
|
||||
@@ -284,11 +304,8 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
return;
|
||||
}
|
||||
const req = parsed as RequestFrame;
|
||||
const respond = (
|
||||
ok: boolean,
|
||||
payload?: unknown,
|
||||
error?: ErrorShape,
|
||||
) => send({ type: "res", id: req.id, ok, payload, error });
|
||||
const respond = (ok: boolean, payload?: unknown, error?: ErrorShape) =>
|
||||
send({ type: "res", id: req.id, ok, payload, error });
|
||||
|
||||
switch (req.method) {
|
||||
case "health": {
|
||||
@@ -308,9 +325,15 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
break;
|
||||
}
|
||||
case "system-event": {
|
||||
const text = String((req.params as { text?: unknown } | undefined)?.text ?? "").trim();
|
||||
const text = String(
|
||||
(req.params as { text?: unknown } | undefined)?.text ?? "",
|
||||
).trim();
|
||||
if (!text) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "text required"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
enqueueSystemEvent(text);
|
||||
@@ -320,7 +343,10 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
{ presence: listSystemPresence() },
|
||||
{
|
||||
dropIfSlow: true,
|
||||
stateVersion: { presence: presenceVersion, health: healthVersion },
|
||||
stateVersion: {
|
||||
presence: presenceVersion,
|
||||
health: healthVersion,
|
||||
},
|
||||
},
|
||||
);
|
||||
respond(true, { ok: true }, undefined);
|
||||
@@ -407,9 +433,14 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
}
|
||||
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
|
||||
// Acknowledge via event to avoid double res frames
|
||||
const ackEvent = {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: { runId, status: "accepted" as const },
|
||||
seq: ++seq,
|
||||
};
|
||||
socket.send(JSON.stringify(ackEvent));
|
||||
try {
|
||||
await agentCommand(
|
||||
{
|
||||
@@ -423,19 +454,43 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
defaultRuntime,
|
||||
deps,
|
||||
);
|
||||
const payload = { runId, status: "ok" as const, summary: "completed" };
|
||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload });
|
||||
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 });
|
||||
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}`));
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`unknown method: ${req.method}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -455,7 +510,10 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
broadcast("shutdown", { reason: "gateway stopping", restartExpectedMs: null });
|
||||
broadcast("shutdown", {
|
||||
reason: "gateway stopping",
|
||||
restartExpectedMs: null,
|
||||
});
|
||||
clearInterval(tickInterval);
|
||||
clearInterval(dedupeCleanup);
|
||||
if (agentUnsub) {
|
||||
|
||||
@@ -108,10 +108,7 @@ export function updateSystemPresence(text: string) {
|
||||
entries.set(key, parsed);
|
||||
}
|
||||
|
||||
export function upsertPresence(
|
||||
key: string,
|
||||
presence: Partial<SystemPresence>,
|
||||
) {
|
||||
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
|
||||
ensureSelfPresence();
|
||||
const existing = entries.get(key) ?? ({} as SystemPresence);
|
||||
const merged: SystemPresence = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
__forceWebChatSnapshotForTests,
|
||||
startWebChatServer,
|
||||
stopWebChatServer,
|
||||
__forceWebChatSnapshotForTests,
|
||||
__broadcastGatewayEventForTests,
|
||||
} from "./server.js";
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
@@ -12,80 +12,83 @@ 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 any).port as number;
|
||||
const address = server.address() as AddressInfo;
|
||||
const port = address.port as number;
|
||||
server.close((err: Error | null) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 8000) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
type SnapshotMessage = {
|
||||
type?: string;
|
||||
snapshot?: { stateVersion?: { presence?: number } };
|
||||
};
|
||||
type SessionMessage = { type?: string };
|
||||
|
||||
describe("webchat server", () => {
|
||||
test("hydrates snapshot to new sockets (offline mock)", { timeout: 8000 }, async () => {
|
||||
const wPort = await getFreePort();
|
||||
await startWebChatServer(wPort, undefined, { disableGateway: true });
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${wPort}/webchat/socket?session=test`);
|
||||
const messages: any[] = [];
|
||||
ws.on("message", (data) => {
|
||||
try {
|
||||
messages.push(JSON.parse(String(data)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
__forceWebChatSnapshotForTests({
|
||||
presence: [],
|
||||
health: {},
|
||||
stateVersion: { presence: 1, health: 1 },
|
||||
uptimeMs: 0,
|
||||
test(
|
||||
"hydrates snapshot to new sockets (offline mock)",
|
||||
{ timeout: 8000 },
|
||||
async () => {
|
||||
const wPort = await getFreePort();
|
||||
await startWebChatServer(wPort, undefined, { disableGateway: true });
|
||||
const ws = new WebSocket(
|
||||
`ws://127.0.0.1:${wPort}/webchat/socket?session=test`,
|
||||
);
|
||||
const messages: unknown[] = [];
|
||||
ws.on("message", (data) => {
|
||||
try {
|
||||
messages.push(JSON.parse(String(data)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
const waitFor = async (pred: (m: any) => boolean, label: string) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 3000) {
|
||||
const found = messages.find((m) => {
|
||||
try {
|
||||
return pred(m);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found) return found;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label}`);
|
||||
};
|
||||
try {
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
await waitFor((m) => m?.type === "session", "session");
|
||||
const snap = await waitFor((m) => m?.type === "gateway-snapshot", "snapshot");
|
||||
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
|
||||
} finally {
|
||||
ws.close();
|
||||
await stopWebChatServer();
|
||||
}
|
||||
});
|
||||
__forceWebChatSnapshotForTests({
|
||||
presence: [],
|
||||
health: {},
|
||||
stateVersion: { presence: 1, health: 1 },
|
||||
uptimeMs: 0,
|
||||
});
|
||||
|
||||
const waitFor = async <T>(
|
||||
pred: (m: unknown) => m is T,
|
||||
label: string,
|
||||
): Promise<T> => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 3000) {
|
||||
const found = messages.find((m): m is T => {
|
||||
try {
|
||||
return pred(m);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found) return found;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label}`);
|
||||
};
|
||||
|
||||
const isSessionMessage = (m: unknown): m is SessionMessage =>
|
||||
typeof m === "object" &&
|
||||
m !== null &&
|
||||
(m as SessionMessage).type === "session";
|
||||
const isSnapshotMessage = (m: unknown): m is SnapshotMessage =>
|
||||
typeof m === "object" &&
|
||||
m !== null &&
|
||||
(m as SnapshotMessage).type === "gateway-snapshot";
|
||||
|
||||
await waitFor(isSessionMessage, "session");
|
||||
const snap = await waitFor(isSnapshotMessage, "snapshot");
|
||||
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
|
||||
} finally {
|
||||
ws.close();
|
||||
await stopWebChatServer();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
|
||||
const WEBCHAT_DEFAULT_PORT = 18788;
|
||||
|
||||
@@ -338,10 +337,20 @@ export async function startWebChatServer(
|
||||
gatewayReady = true;
|
||||
latestSnapshot = hello.snapshot as Record<string, unknown>;
|
||||
latestPolicy = hello.policy as Record<string, unknown>;
|
||||
broadcastAll({ type: "gateway-snapshot", snapshot: hello.snapshot, policy: hello.policy });
|
||||
broadcastAll({
|
||||
type: "gateway-snapshot",
|
||||
snapshot: hello.snapshot,
|
||||
policy: hello.policy,
|
||||
});
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
broadcastAll({ type: "gateway-event", event: evt.event, payload: evt.payload, seq: evt.seq, stateVersion: evt.stateVersion });
|
||||
broadcastAll({
|
||||
type: "gateway-event",
|
||||
event: evt.event,
|
||||
payload: evt.payload,
|
||||
seq: evt.seq,
|
||||
stateVersion: evt.stateVersion,
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
gatewayReady = false;
|
||||
@@ -517,10 +526,17 @@ export function __forceWebChatSnapshotForTests(
|
||||
latestSnapshot = snapshot;
|
||||
latestPolicy = policy ?? null;
|
||||
gatewayReady = true;
|
||||
broadcastAll({ type: "gateway-snapshot", snapshot: latestSnapshot, policy: latestPolicy });
|
||||
broadcastAll({
|
||||
type: "gateway-snapshot",
|
||||
snapshot: latestSnapshot,
|
||||
policy: latestPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function __broadcastGatewayEventForTests(event: string, payload: unknown) {
|
||||
export function __broadcastGatewayEventForTests(
|
||||
event: string,
|
||||
payload: unknown,
|
||||
) {
|
||||
broadcastAll({ type: "gateway-event", event, payload });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user