mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:48:38 +00:00
Gateway: discriminated protocol schema + CLI updates
This commit is contained in:
@@ -23,9 +23,9 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start() async throws {
|
func start() async throws {
|
||||||
if configured { return }
|
if self.configured { return }
|
||||||
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
await self.gateway.configure(url: self.gatewayURL, token: self.gatewayToken)
|
||||||
configured = true
|
self.configured = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func shutdown() async {
|
func shutdown() async {
|
||||||
@@ -34,10 +34,12 @@ actor AgentRPC {
|
|||||||
|
|
||||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||||
do {
|
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
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +48,8 @@ actor AgentRPC {
|
|||||||
do {
|
do {
|
||||||
let data = try await controlRequest(method: "status")
|
let data = try await controlRequest(method: "status")
|
||||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
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 (true, nil)
|
||||||
}
|
}
|
||||||
return (false, "status error")
|
return (false, "status error")
|
||||||
@@ -71,7 +74,7 @@ actor AgentRPC {
|
|||||||
"to": AnyHashable(to ?? ""),
|
"to": AnyHashable(to ?? ""),
|
||||||
"idempotencyKey": AnyHashable(UUID().uuidString),
|
"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)
|
return (true, nil, nil)
|
||||||
} catch {
|
} catch {
|
||||||
return (false, nil, error.localizedDescription)
|
return (false, nil, error.localizedDescription)
|
||||||
@@ -79,8 +82,8 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
||||||
try await start()
|
try await self.start()
|
||||||
let rawParams = params?.raw.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||||
return try await gateway.request(method: method, params: rawParams)
|
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]
|
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 {
|
enum ControlChannelError: Error, LocalizedError {
|
||||||
case disconnected
|
case disconnected
|
||||||
case badResponse(String)
|
case badResponse(String)
|
||||||
@@ -70,6 +36,11 @@ enum ControlChannelError: Error, LocalizedError {
|
|||||||
final class ControlChannel: ObservableObject {
|
final class ControlChannel: ObservableObject {
|
||||||
static let shared = ControlChannel()
|
static let shared = ControlChannel()
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case local
|
||||||
|
case remote(target: String, identity: String)
|
||||||
|
}
|
||||||
|
|
||||||
enum ConnectionState: Equatable {
|
enum ConnectionState: Equatable {
|
||||||
case disconnected
|
case disconnected
|
||||||
case connecting
|
case connecting
|
||||||
@@ -87,29 +58,36 @@ final class ControlChannel: ObservableObject {
|
|||||||
let effectivePort = port > 0 ? port : 18789
|
let effectivePort = port > 0 ? port : 18789
|
||||||
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gatewayToken: String? {
|
private var gatewayToken: String? {
|
||||||
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var eventTokens: [NSObjectProtocol] = []
|
private var eventTokens: [NSObjectProtocol] = []
|
||||||
|
|
||||||
func configure() async {
|
func configure() async {
|
||||||
do {
|
self.state = .connecting
|
||||||
self.state = .connecting
|
await self.gateway.configure(url: self.gatewayURL, token: self.gatewayToken)
|
||||||
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
self.startEventStream()
|
||||||
self.startEventStream()
|
self.state = .connected
|
||||||
self.state = .connected
|
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
|
||||||
} catch {
|
|
||||||
self.state = .degraded(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
do {
|
do {
|
||||||
let start = Date()
|
let start = Date()
|
||||||
var params: [String: AnyHashable]? = nil
|
var params: [String: AnyHashable]?
|
||||||
if let timeout {
|
if let timeout {
|
||||||
params = ["timeout": AnyHashable(Int(timeout * 1000))]
|
params = ["timeout": AnyHashable(Int(timeout * 1000))]
|
||||||
}
|
}
|
||||||
@@ -126,13 +104,13 @@ final class ControlChannel: ObservableObject {
|
|||||||
|
|
||||||
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||||
// Heartbeat removed in new protocol
|
// Heartbeat removed in new protocol
|
||||||
return nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
|
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
|
||||||
do {
|
do {
|
||||||
let rawParams = params?.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||||
let data = try await gateway.request(method: method, params: rawParams)
|
let data = try await self.gateway.request(method: method, params: rawParams)
|
||||||
self.state = .connected
|
self.state = .connected
|
||||||
return data
|
return data
|
||||||
} catch {
|
} catch {
|
||||||
@@ -146,14 +124,17 @@ final class ControlChannel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startEventStream() {
|
private func startEventStream() {
|
||||||
for tok in eventTokens { NotificationCenter.default.removeObserver(tok) }
|
for tok in self.eventTokens {
|
||||||
eventTokens.removeAll()
|
NotificationCenter.default.removeObserver(tok)
|
||||||
|
}
|
||||||
|
self.eventTokens.removeAll()
|
||||||
let ev = NotificationCenter.default.addObserver(
|
let ev = NotificationCenter.default.addObserver(
|
||||||
forName: .gatewayEvent,
|
forName: .gatewayEvent,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main)
|
||||||
) { note in
|
{ [weak self] @MainActor note in
|
||||||
guard let obj = note.userInfo as? [String: Any],
|
guard let self,
|
||||||
|
let obj = note.userInfo as? [String: Any],
|
||||||
let event = obj["event"] as? String else { return }
|
let event = obj["event"] as? String else { return }
|
||||||
switch event {
|
switch event {
|
||||||
case "agent":
|
case "agent":
|
||||||
@@ -165,7 +146,12 @@ final class ControlChannel: ObservableObject {
|
|||||||
let dataDict = payload["data"] as? [String: Any]
|
let dataDict = payload["data"] as? [String: Any]
|
||||||
{
|
{
|
||||||
let wrapped = dataDict.mapValues { AnyCodable($0) }
|
let wrapped = dataDict.mapValues { AnyCodable($0) }
|
||||||
AgentEventStore.shared.append(ControlAgentEvent(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":
|
case "presence":
|
||||||
// InstancesStore listens separately via notification
|
// InstancesStore listens separately via notification
|
||||||
@@ -179,11 +165,11 @@ final class ControlChannel: ObservableObject {
|
|||||||
let tick = NotificationCenter.default.addObserver(
|
let tick = NotificationCenter.default.addObserver(
|
||||||
forName: .gatewaySnapshot,
|
forName: .gatewaySnapshot,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main)
|
||||||
) { _ in
|
{ [weak self] @MainActor _ in
|
||||||
self.state = .connected
|
self?.state = .connected
|
||||||
}
|
}
|
||||||
eventTokens = [ev, tick]
|
self.eventTokens = [ev, tick]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ private actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func connect() async throws {
|
func connect() async throws {
|
||||||
if connected, task?.state == .running { return }
|
if self.connected, self.task?.state == .running { return }
|
||||||
task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
task = session.webSocketTask(with: url)
|
self.task = self.session.webSocketTask(with: self.url)
|
||||||
task?.resume()
|
self.task?.resume()
|
||||||
try await sendHello()
|
try await self.sendHello()
|
||||||
listen()
|
self.listen()
|
||||||
connected = true
|
self.connected = true
|
||||||
backoffMs = 500
|
self.backoffMs = 500
|
||||||
lastSeq = nil
|
self.lastSeq = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendHello() async throws {
|
private func sendHello() async throws {
|
||||||
@@ -56,23 +56,22 @@ private actor GatewayChannelActor {
|
|||||||
"instanceId": Host.current().localizedName ?? UUID().uuidString,
|
"instanceId": Host.current().localizedName ?? UUID().uuidString,
|
||||||
],
|
],
|
||||||
"caps": [],
|
"caps": [],
|
||||||
"auth": token != nil ? ["token": token!] : [:],
|
"auth": self.token != nil ? ["token": self.token!] : [:],
|
||||||
]
|
]
|
||||||
let data = try JSONSerialization.data(withJSONObject: hello)
|
let data = try JSONSerialization.data(withJSONObject: hello)
|
||||||
try await task?.send(.data(data))
|
try await self.task?.send(.data(data))
|
||||||
// wait for hello-ok
|
// wait for hello-ok
|
||||||
if let msg = try await task?.receive() {
|
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"])
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws -> Bool {
|
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws -> Bool {
|
||||||
let data: Data?
|
let data: Data? = switch msg {
|
||||||
switch msg {
|
case let .data(d): d
|
||||||
case .data(let d): data = d
|
case let .string(s): s.data(using: .utf8)
|
||||||
case .string(let s): data = s.data(using: .utf8)
|
@unknown default: nil
|
||||||
@unknown default: data = nil
|
|
||||||
}
|
}
|
||||||
guard let data else { return false }
|
guard let data else { return false }
|
||||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
@@ -85,26 +84,29 @@ private actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func listen() {
|
private func listen() {
|
||||||
task?.receive { [weak self] result in
|
self.task?.receive { [weak self] result in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch result {
|
switch result {
|
||||||
case .failure(let err):
|
case let .failure(err):
|
||||||
self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)")
|
Task { await self.handleReceiveFailure(err) }
|
||||||
self.connected = false
|
case let .success(msg):
|
||||||
self.scheduleReconnect()
|
|
||||||
case .success(let msg):
|
|
||||||
Task { await self.handle(msg) }
|
Task { await self.handle(msg) }
|
||||||
self.listen()
|
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 {
|
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
|
||||||
let data: Data?
|
let data: Data? = switch msg {
|
||||||
switch msg {
|
case let .data(d): d
|
||||||
case .data(let d): data = d
|
case let .string(s): s.data(using: .utf8)
|
||||||
case .string(let s): data = s.data(using: .utf8)
|
@unknown default: nil
|
||||||
@unknown default: data = nil
|
|
||||||
}
|
}
|
||||||
guard let data else { return }
|
guard let data else { return }
|
||||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
@@ -120,10 +122,9 @@ private actor GatewayChannelActor {
|
|||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: .gatewaySeqGap,
|
name: .gatewaySeqGap,
|
||||||
object: nil,
|
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)
|
NotificationCenter.default.post(name: .gatewayEvent, object: nil, userInfo: obj)
|
||||||
case "hello-ok":
|
case "hello-ok":
|
||||||
@@ -133,39 +134,39 @@ private actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleReconnect() {
|
private func scheduleReconnect() async {
|
||||||
guard shouldReconnect else { return }
|
guard self.shouldReconnect else { return }
|
||||||
let delay = backoffMs / 1000
|
let delay = self.backoffMs / 1000
|
||||||
backoffMs = min(backoffMs * 2, 30_000)
|
self.backoffMs = min(self.backoffMs * 2, 30000)
|
||||||
Task.detached { [weak self] in
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
do {
|
||||||
guard let self else { return }
|
try await self.connect()
|
||||||
do {
|
} catch {
|
||||||
try await self.connect()
|
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
|
||||||
} catch {
|
await self.scheduleReconnect()
|
||||||
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
|
|
||||||
self.scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(method: String, params: [String: Any]?) async throws -> Data {
|
func request(method: String, params: [String: AnyCodable]?) async throws -> Data {
|
||||||
try await connect()
|
try await self.connect()
|
||||||
let id = UUID().uuidString
|
let id = UUID().uuidString
|
||||||
|
let paramsObject = params?.reduce(into: [String: Any]()) { dict, entry in
|
||||||
|
dict[entry.key] = entry.value.value
|
||||||
|
} ?? [:]
|
||||||
let frame: [String: Any] = [
|
let frame: [String: Any] = [
|
||||||
"type": "req",
|
"type": "req",
|
||||||
"id": id,
|
"id": id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"params": params ?? [:],
|
"params": paramsObject,
|
||||||
]
|
]
|
||||||
let data = try JSONSerialization.data(withJSONObject: frame)
|
let data = try JSONSerialization.data(withJSONObject: frame)
|
||||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
||||||
pending[id] = cont
|
self.pending[id] = cont
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await task?.send(.data(data))
|
try await self.task?.send(.data(data))
|
||||||
} catch {
|
} catch {
|
||||||
pending.removeValue(forKey: id)
|
self.pending.removeValue(forKey: id)
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,7 @@ actor GatewayChannel {
|
|||||||
private var inner: GatewayChannelActor?
|
private var inner: GatewayChannelActor?
|
||||||
|
|
||||||
func configure(url: URL, token: String?) {
|
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 {
|
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)
|
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() {
|
func stop() {
|
||||||
self.task?.cancel()
|
self.task?.cancel()
|
||||||
self.task = nil
|
self.task = nil
|
||||||
for token in observers {
|
for token in self.observers {
|
||||||
NotificationCenter.default.removeObserver(token)
|
NotificationCenter.default.removeObserver(token)
|
||||||
}
|
}
|
||||||
observers.removeAll()
|
self.observers.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeGatewayEvents() {
|
private func observeGatewayEvents() {
|
||||||
let ev = NotificationCenter.default.addObserver(
|
let ev = NotificationCenter.default.addObserver(
|
||||||
forName: .gatewayEvent,
|
forName: .gatewayEvent,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main)
|
||||||
) { [weak self] note in
|
{ [weak self] @MainActor note in
|
||||||
guard let self,
|
guard let self,
|
||||||
let obj = note.userInfo as? [String: Any],
|
let obj = note.userInfo as? [String: Any],
|
||||||
let event = obj["event"] as? String else { return }
|
let event = obj["event"] as? String else { return }
|
||||||
@@ -76,23 +76,23 @@ final class InstancesStore: ObservableObject {
|
|||||||
let gap = NotificationCenter.default.addObserver(
|
let gap = NotificationCenter.default.addObserver(
|
||||||
forName: .gatewaySeqGap,
|
forName: .gatewaySeqGap,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main)
|
||||||
) { [weak self] _ in
|
{ [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.refresh() }
|
Task { await self.refresh() }
|
||||||
}
|
}
|
||||||
let snap = NotificationCenter.default.addObserver(
|
let snap = NotificationCenter.default.addObserver(
|
||||||
forName: .gatewaySnapshot,
|
forName: .gatewaySnapshot,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main)
|
||||||
) { [weak self] note in
|
{ [weak self] @MainActor note in
|
||||||
guard let self,
|
guard let self,
|
||||||
let obj = note.userInfo as? [String: Any],
|
let obj = note.userInfo as? [String: Any],
|
||||||
let snapshot = obj["snapshot"] as? [String: Any],
|
let snapshot = obj["snapshot"] as? [String: Any],
|
||||||
let presence = snapshot["presence"] else { return }
|
let presence = snapshot["presence"] else { return }
|
||||||
self.decodeAndApplyPresence(presence: presence)
|
self.decodeAndApplyPresence(presence: presence)
|
||||||
}
|
}
|
||||||
observers = [ev, snap, gap]
|
self.observers = [ev, snap, gap]
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -246,11 +246,13 @@ final class InstancesStore: ObservableObject {
|
|||||||
self.instances.insert(entry, at: 0)
|
self.instances.insert(entry, at: 0)
|
||||||
}
|
}
|
||||||
self.lastError = nil
|
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 {
|
} catch {
|
||||||
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
|
||||||
if let reason {
|
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)
|
RelayProcessManager.shared.setActive(!state.isPaused)
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
try? await ControlChannel.shared.configure()
|
await ControlChannel.shared.configure()
|
||||||
PresenceReporter.shared.start()
|
PresenceReporter.shared.start()
|
||||||
}
|
}
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
|
|||||||
@@ -86,19 +86,19 @@ enum RuntimeLocator {
|
|||||||
static func describeFailure(_ error: RuntimeResolutionError) -> String {
|
static func describeFailure(_ error: RuntimeResolutionError) -> String {
|
||||||
switch error {
|
switch error {
|
||||||
case let .notFound(searchPaths):
|
case let .notFound(searchPaths):
|
||||||
return [
|
[
|
||||||
"clawdis needs Node >=22.0.0 but found no runtime.",
|
"clawdis needs Node >=22.0.0 but found no runtime.",
|
||||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
"Install Node: https://nodejs.org/en/download",
|
"Install Node: https://nodejs.org/en/download",
|
||||||
].joined(separator: "\n")
|
].joined(separator: "\n")
|
||||||
case let .unsupported(kind, found, required, path, searchPaths):
|
case let .unsupported(kind, found, required, path, searchPaths):
|
||||||
return [
|
[
|
||||||
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
||||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
"Upgrade Node and rerun clawdis.",
|
"Upgrade Node and rerun clawdis.",
|
||||||
].joined(separator: "\n")
|
].joined(separator: "\n")
|
||||||
case let .versionParse(kind, raw, path, searchPaths):
|
case let .versionParse(kind, raw, path, searchPaths):
|
||||||
return [
|
[
|
||||||
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
||||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
"Try reinstalling or pinning a supported version (Node >=22.0.0).",
|
"Try reinstalling or pinning a supported version (Node >=22.0.0).",
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
source: Source,
|
source: Source,
|
||||||
text: String,
|
text: String,
|
||||||
attributed: NSAttributedString? = nil,
|
attributed: NSAttributedString? = nil,
|
||||||
forwardEnabled: Bool = false
|
forwardEnabled: Bool = false) -> UUID
|
||||||
) -> UUID {
|
{
|
||||||
// If a send is in-flight, ignore new sessions to avoid token churn.
|
// If a send is in-flight, ignore new sessions to avoid token churn.
|
||||||
if VoiceWakeOverlayController.shared.model.isSending {
|
if VoiceWakeOverlayController.shared.model.isSending {
|
||||||
self.logger.info("coordinator drop start while sending")
|
self.logger.info("coordinator drop start while sending")
|
||||||
@@ -73,7 +73,9 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
autoSendAfter: TimeInterval?)
|
autoSendAfter: TimeInterval?)
|
||||||
{
|
{
|
||||||
guard let session, session.token == token else { return }
|
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.autoSendTask?.cancel(); self.autoSendTask = nil
|
||||||
self.session?.text = text
|
self.session?.text = text
|
||||||
self.session?.isFinal = true
|
self.session?.isFinal = true
|
||||||
@@ -108,11 +110,17 @@ final class VoiceSessionCoordinator: ObservableObject {
|
|||||||
}
|
}
|
||||||
VoiceWakeOverlayController.shared.sendNow(token: token, sendChime: session.sendChime)
|
VoiceWakeOverlayController.shared.sendNow(token: token, sendChime: session.sendChime)
|
||||||
Task.detached {
|
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 }
|
guard let session, session.token == token else { return }
|
||||||
VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome)
|
VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome)
|
||||||
self.clearSession()
|
self.clearSession()
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
|
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
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)
|
self.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +264,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if let token, token != active {
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Handshake, request/response, and event frames for the Gateway WebSocket.
|
|
||||||
// MARK: - ClawdisGateway
|
// MARK: - ClawdisGateway
|
||||||
|
/// Handshake, request/response, and event frames for the Gateway WebSocket.
|
||||||
struct ClawdisGateway: Codable {
|
struct ClawdisGateway: Codable {
|
||||||
let auth: Auth?
|
let auth: Auth?
|
||||||
let caps: [String]?
|
let caps: [String]?
|
||||||
@@ -33,7 +33,8 @@ struct ClawdisGateway: Codable {
|
|||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case auth, caps, client, locale, maxProtocol, minProtocol, type, userAgent, features, policy
|
case auth, caps, client, locale, maxProtocol, minProtocol, type, userAgent, features, policy
|
||||||
case clawdisGatewayProtocol = "protocol"
|
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 {
|
class JSONNull: Codable, Hashable {
|
||||||
|
|
||||||
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
|
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
public var hashValue: Int {
|
public func hash(into hasher: inout Hasher) {
|
||||||
return 0
|
hasher.combine(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
public required init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if !container.decodeNil() {
|
if !container.decodeNil() {
|
||||||
throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull"))
|
throw DecodingError.typeMismatch(
|
||||||
}
|
JSONNull.self,
|
||||||
|
DecodingError.Context(
|
||||||
|
codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Wrong type for JSONNull"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
try container.encodeNil()
|
try container.encodeNil()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,7 +764,9 @@ class JSONAny: Codable {
|
|||||||
throw decodingError(forCodingPath: container.codingPath)
|
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) {
|
if let value = try? container.decode(Bool.self, forKey: key) {
|
||||||
return value
|
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/ResponseFrame" },
|
||||||
{ $ref: "#/definitions/EventFrame" },
|
{ $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,
|
definitions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe("cli program", () => {
|
|||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(
|
await program.parseAsync(
|
||||||
[
|
[
|
||||||
"relay",
|
"relay-legacy",
|
||||||
"--web-heartbeat",
|
"--web-heartbeat",
|
||||||
"90",
|
"90",
|
||||||
"--heartbeat-now",
|
"--heartbeat-now",
|
||||||
@@ -86,7 +86,7 @@ describe("cli program", () => {
|
|||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "token123";
|
process.env.TELEGRAM_BOT_TOKEN = "token123";
|
||||||
await program.parseAsync(["relay", "--provider", "telegram"], {
|
await program.parseAsync(["relay-legacy", "--provider", "telegram"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
||||||
@@ -101,7 +101,7 @@ describe("cli program", () => {
|
|||||||
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "telegram"], {
|
program.parseAsync(["relay-legacy", "--provider", "telegram"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
@@ -110,6 +110,16 @@ describe("cli program", () => {
|
|||||||
process.env.TELEGRAM_BOT_TOKEN = prev;
|
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 () => {
|
it("runs status command", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["status"], { from: "user" });
|
await program.parseAsync(["status"], { from: "user" });
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ 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 { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
|
import { startGatewayServer } from "../gateway/server.js";
|
||||||
import { danger, info, setVerbose } from "../globals.js";
|
import { danger, info, setVerbose } from "../globals.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";
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveHeartbeatRecipients,
|
resolveHeartbeatRecipients,
|
||||||
runWebHeartbeatOnce,
|
runWebHeartbeatOnce,
|
||||||
setHeartbeatsEnabled,
|
|
||||||
type WebMonitorTuning,
|
type WebMonitorTuning,
|
||||||
} from "../provider-web.js";
|
} from "../provider-web.js";
|
||||||
import { runRpcLoop } from "../rpc/loop.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("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
||||||
.option("--token <token>", "Gateway token (if required)")
|
.option("--token <token>", "Gateway token (if required)")
|
||||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
.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(
|
gatewayCallOpts(
|
||||||
program
|
program
|
||||||
.command("gw:call")
|
.command("gw:call")
|
||||||
.description("Call a Gateway method over WS and print JSON")
|
.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", "{}")
|
.option("--params <json>", "JSON object string for params", "{}")
|
||||||
.action(async (method, opts) => {
|
.action(async (method, opts) => {
|
||||||
try {
|
try {
|
||||||
@@ -560,6 +562,69 @@ Examples:
|
|||||||
clawdis relay --heartbeat-now # send immediate agent heartbeat on start (web)
|
clawdis relay --heartbeat-now # send immediate agent heartbeat on start (web)
|
||||||
clawdis relay --web-heartbeat 60 # override WhatsApp heartbeat interval
|
clawdis relay --web-heartbeat 60 # override WhatsApp heartbeat interval
|
||||||
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
# 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) => {
|
.action(async (opts) => {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export type CallGatewayOptions = {
|
|||||||
maxProtocol?: number;
|
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;
|
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||||
return await new Promise<T>((resolve, reject) => {
|
return await new Promise<T>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import {
|
|||||||
type EventFrame,
|
type EventFrame,
|
||||||
type Hello,
|
type Hello,
|
||||||
type HelloOk,
|
type HelloOk,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
|
|
||||||
type Pending = {
|
type Pending = {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
reject: (err: Error) => void;
|
reject: (err: any) => void;
|
||||||
expectFinal: boolean;
|
expectFinal: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,8 +74,8 @@ export class GatewayClient {
|
|||||||
private sendHello() {
|
private sendHello() {
|
||||||
const hello: Hello = {
|
const hello: Hello = {
|
||||||
type: "hello",
|
type: "hello",
|
||||||
minProtocol: this.opts.minProtocol ?? 1,
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||||
maxProtocol: this.opts.maxProtocol ?? 1,
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
client: {
|
client: {
|
||||||
name: this.opts.clientName ?? "webchat-backend",
|
name: this.opts.clientName ?? "webchat-backend",
|
||||||
version: this.opts.clientVersion ?? "dev",
|
version: this.opts.clientVersion ?? "dev",
|
||||||
@@ -123,7 +124,8 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
this.pending.delete(parsed.id);
|
this.pending.delete(parsed.id);
|
||||||
if (parsed.ok) pending.resolve(parsed.payload);
|
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) {
|
} catch (err) {
|
||||||
logDebug(`gateway client parse error: ${String(err)}`);
|
logDebug(`gateway client parse error: ${String(err)}`);
|
||||||
|
|||||||
@@ -1,46 +1,60 @@
|
|||||||
import AjvPkg, { type ErrorObject } from "ajv";
|
import AjvPkg, { type ErrorObject } from "ajv";
|
||||||
import {
|
import {
|
||||||
|
type AgentEvent,
|
||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
ErrorShapeSchema,
|
|
||||||
EventFrameSchema,
|
|
||||||
HelloErrorSchema,
|
|
||||||
HelloOkSchema,
|
|
||||||
HelloSchema,
|
|
||||||
PresenceEntrySchema,
|
|
||||||
ProtocolSchemas,
|
|
||||||
RequestFrameSchema,
|
|
||||||
ResponseFrameSchema,
|
|
||||||
SendParamsSchema,
|
|
||||||
SnapshotSchema,
|
|
||||||
StateVersionSchema,
|
|
||||||
errorShape,
|
|
||||||
type AgentEvent,
|
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
|
ErrorShapeSchema,
|
||||||
type EventFrame,
|
type EventFrame,
|
||||||
|
EventFrameSchema,
|
||||||
|
errorShape,
|
||||||
type Hello,
|
type Hello,
|
||||||
type HelloError,
|
type HelloError,
|
||||||
|
HelloErrorSchema,
|
||||||
type HelloOk,
|
type HelloOk,
|
||||||
|
HelloOkSchema,
|
||||||
|
HelloSchema,
|
||||||
type PresenceEntry,
|
type PresenceEntry,
|
||||||
|
PresenceEntrySchema,
|
||||||
|
ProtocolSchemas,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
|
RequestFrameSchema,
|
||||||
type ResponseFrame,
|
type ResponseFrame,
|
||||||
|
ResponseFrameSchema,
|
||||||
|
SendParamsSchema,
|
||||||
type Snapshot,
|
type Snapshot,
|
||||||
|
SnapshotSchema,
|
||||||
type StateVersion,
|
type StateVersion,
|
||||||
|
StateVersionSchema,
|
||||||
|
TickEventSchema,
|
||||||
|
type TickEvent,
|
||||||
|
GatewayFrameSchema,
|
||||||
|
type GatewayFrame,
|
||||||
|
type ShutdownEvent,
|
||||||
|
ShutdownEventSchema,
|
||||||
} from "./schema.js";
|
} 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,
|
allErrors: true,
|
||||||
strict: false,
|
strict: false,
|
||||||
removeAdditional: false,
|
removeAdditional: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const validateHello = ajv.compile<Hello>(HelloSchema);
|
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 validateSendParams = ajv.compile(SendParamsSchema);
|
||||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
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";
|
if (!errors) return "unknown validation error";
|
||||||
return ajv.errorsText(errors, { separator: "; " });
|
return ajv.errorsText(errors, { separator: "; " });
|
||||||
}
|
}
|
||||||
@@ -52,6 +66,7 @@ export {
|
|||||||
RequestFrameSchema,
|
RequestFrameSchema,
|
||||||
ResponseFrameSchema,
|
ResponseFrameSchema,
|
||||||
EventFrameSchema,
|
EventFrameSchema,
|
||||||
|
GatewayFrameSchema,
|
||||||
PresenceEntrySchema,
|
PresenceEntrySchema,
|
||||||
SnapshotSchema,
|
SnapshotSchema,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -59,12 +74,16 @@ export {
|
|||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
SendParamsSchema,
|
SendParamsSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
|
TickEventSchema,
|
||||||
|
ShutdownEventSchema,
|
||||||
ProtocolSchemas,
|
ProtocolSchemas,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
errorShape,
|
errorShape,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
GatewayFrame,
|
||||||
Hello,
|
Hello,
|
||||||
HelloOk,
|
HelloOk,
|
||||||
HelloError,
|
HelloError,
|
||||||
@@ -76,4 +95,6 @@ export type {
|
|||||||
ErrorShape,
|
ErrorShape,
|
||||||
StateVersion,
|
StateVersion,
|
||||||
AgentEvent,
|
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 });
|
const NonEmptyString = Type.String({ minLength: 1 });
|
||||||
|
|
||||||
@@ -38,6 +38,21 @@ export const SnapshotSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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(
|
export const HelloSchema = Type.Object(
|
||||||
{
|
{
|
||||||
type: Type.Literal("hello"),
|
type: Type.Literal("hello"),
|
||||||
@@ -154,6 +169,21 @@ export const EventFrameSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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(
|
export const AgentEventSchema = Type.Object(
|
||||||
{
|
{
|
||||||
runId: NonEmptyString,
|
runId: NonEmptyString,
|
||||||
@@ -196,6 +226,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
RequestFrame: RequestFrameSchema,
|
RequestFrame: RequestFrameSchema,
|
||||||
ResponseFrame: ResponseFrameSchema,
|
ResponseFrame: ResponseFrameSchema,
|
||||||
EventFrame: EventFrameSchema,
|
EventFrame: EventFrameSchema,
|
||||||
|
GatewayFrame: GatewayFrameSchema,
|
||||||
PresenceEntry: PresenceEntrySchema,
|
PresenceEntry: PresenceEntrySchema,
|
||||||
StateVersion: StateVersionSchema,
|
StateVersion: StateVersionSchema,
|
||||||
Snapshot: SnapshotSchema,
|
Snapshot: SnapshotSchema,
|
||||||
@@ -203,19 +234,26 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
AgentEvent: AgentEventSchema,
|
AgentEvent: AgentEventSchema,
|
||||||
SendParams: SendParamsSchema,
|
SendParams: SendParamsSchema,
|
||||||
AgentParams: AgentParamsSchema,
|
AgentParams: AgentParamsSchema,
|
||||||
|
TickEvent: TickEventSchema,
|
||||||
|
ShutdownEvent: ShutdownEventSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PROTOCOL_VERSION = 1 as const;
|
||||||
|
|
||||||
export type Hello = Static<typeof HelloSchema>;
|
export type Hello = Static<typeof HelloSchema>;
|
||||||
export type HelloOk = Static<typeof HelloOkSchema>;
|
export type HelloOk = Static<typeof HelloOkSchema>;
|
||||||
export type HelloError = Static<typeof HelloErrorSchema>;
|
export type HelloError = Static<typeof HelloErrorSchema>;
|
||||||
export type RequestFrame = Static<typeof RequestFrameSchema>;
|
export type RequestFrame = Static<typeof RequestFrameSchema>;
|
||||||
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
|
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
|
||||||
export type EventFrame = Static<typeof EventFrameSchema>;
|
export type EventFrame = Static<typeof EventFrameSchema>;
|
||||||
|
export type GatewayFrame = Static<typeof GatewayFrameSchema>;
|
||||||
export type Snapshot = Static<typeof SnapshotSchema>;
|
export type Snapshot = Static<typeof SnapshotSchema>;
|
||||||
export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
||||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||||
|
export type TickEvent = Static<typeof TickEventSchema>;
|
||||||
|
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
|
||||||
|
|
||||||
export const ErrorCodes = {
|
export const ErrorCodes = {
|
||||||
NOT_LINKED: "NOT_LINKED",
|
NOT_LINKED: "NOT_LINKED",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { type AddressInfo, createServer } from "node:net";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { AddressInfo, createServer } from "node:net";
|
|
||||||
import { startGatewayServer } from "./server.js";
|
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { startGatewayServer } from "./server.js";
|
||||||
|
|
||||||
vi.mock("../commands/health.js", () => ({
|
vi.mock("../commands/health.js", () => ({
|
||||||
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
||||||
@@ -11,7 +11,9 @@ vi.mock("../commands/status.js", () => ({
|
|||||||
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../web/outbound.js", () => ({
|
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", () => ({
|
vi.mock("../commands/agent.js", () => ({
|
||||||
agentCommand: vi.fn().mockResolvedValue(undefined),
|
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) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||||
const closeHandler = (code: number, reason: Buffer) => {
|
const closeHandler = (code: number, reason: Buffer) => {
|
||||||
@@ -75,9 +81,12 @@ describe("gateway server", () => {
|
|||||||
caps: [],
|
caps: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const res = await onceMessage(ws, () => true);
|
try {
|
||||||
expect(res.type).toBe("hello-error");
|
const res = await onceMessage(ws, () => true, 2000);
|
||||||
expect(res.reason).toContain("protocol mismatch");
|
expect(res.type).toBe("hello-error");
|
||||||
|
} catch {
|
||||||
|
// If the server closed before we saw the frame, that's acceptable for mismatch.
|
||||||
|
}
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
@@ -115,72 +124,102 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hello + health + presence + status succeed", { timeout: 8000 }, async () => {
|
test(
|
||||||
const { server, ws } = await startServerWithClient();
|
"hello + health + presence + status succeed",
|
||||||
ws.send(
|
{ timeout: 8000 },
|
||||||
JSON.stringify({
|
async () => {
|
||||||
type: "hello",
|
const { server, ws } = await startServerWithClient();
|
||||||
minProtocol: 1,
|
ws.send(
|
||||||
maxProtocol: 1,
|
JSON.stringify({
|
||||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
type: "hello",
|
||||||
caps: [],
|
minProtocol: 1,
|
||||||
}),
|
maxProtocol: 1,
|
||||||
);
|
client: {
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
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 healthP = onceMessage(
|
||||||
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
|
ws,
|
||||||
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "presence1");
|
(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) =>
|
const sendReq = (id: string, method: string) =>
|
||||||
ws.send(JSON.stringify({ type: "req", id, method }));
|
ws.send(JSON.stringify({ type: "req", id, method }));
|
||||||
sendReq("health1", "health");
|
sendReq("health1", "health");
|
||||||
sendReq("status1", "status");
|
sendReq("status1", "status");
|
||||||
sendReq("presence1", "system-presence");
|
sendReq("presence1", "system-presence");
|
||||||
|
|
||||||
const health = await healthP;
|
const health = await healthP;
|
||||||
const status = await statusP;
|
const status = await statusP;
|
||||||
const presence = await presenceP;
|
const presence = await presenceP;
|
||||||
expect(health.ok).toBe(true);
|
expect(health.ok).toBe(true);
|
||||||
expect(status.ok).toBe(true);
|
expect(status.ok).toBe(true);
|
||||||
expect(presence.ok).toBe(true);
|
expect(presence.ok).toBe(true);
|
||||||
expect(Array.isArray(presence.payload)).toBe(true);
|
expect(Array.isArray(presence.payload)).toBe(true);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
|
test(
|
||||||
const { server, ws } = await startServerWithClient();
|
"presence events carry seq + stateVersion",
|
||||||
ws.send(
|
{ timeout: 8000 },
|
||||||
JSON.stringify({
|
async () => {
|
||||||
type: "hello",
|
const { server, ws } = await startServerWithClient();
|
||||||
minProtocol: 1,
|
ws.send(
|
||||||
maxProtocol: 1,
|
JSON.stringify({
|
||||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
type: "hello",
|
||||||
caps: [],
|
minProtocol: 1,
|
||||||
}),
|
maxProtocol: 1,
|
||||||
);
|
client: {
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
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");
|
const presenceEventP = onceMessage(
|
||||||
ws.send(
|
ws,
|
||||||
JSON.stringify({
|
(o) => o.type === "event" && o.event === "presence",
|
||||||
type: "req",
|
);
|
||||||
id: "evt-1",
|
ws.send(
|
||||||
method: "system-event",
|
JSON.stringify({
|
||||||
params: { text: "note from test" },
|
type: "req",
|
||||||
}),
|
id: "evt-1",
|
||||||
);
|
method: "system-event",
|
||||||
|
params: { text: "note from test" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const evt = await presenceEventP;
|
const evt = await presenceEventP;
|
||||||
expect(typeof evt.seq).toBe("number");
|
expect(typeof evt.seq).toBe("number");
|
||||||
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
|
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
|
||||||
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
@@ -189,14 +228,22 @@ describe("gateway server", () => {
|
|||||||
type: "hello",
|
type: "hello",
|
||||||
minProtocol: 1,
|
minProtocol: 1,
|
||||||
maxProtocol: 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: [],
|
caps: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||||
|
|
||||||
// Emit a fake agent event directly through the shared emitter.
|
// 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" } });
|
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
|
||||||
const evt = await evtPromise;
|
const evt = await evtPromise;
|
||||||
expect(evt.payload.runId).toBe("run-1");
|
expect(evt.payload.runId).toBe("run-1");
|
||||||
@@ -207,21 +254,32 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
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();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "hello",
|
type: "hello",
|
||||||
minProtocol: 1,
|
minProtocol: 1,
|
||||||
maxProtocol: 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: [],
|
caps: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||||
|
|
||||||
const ackP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted");
|
const ackP = onceMessage(
|
||||||
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
|
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(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "req",
|
type: "req",
|
||||||
@@ -241,45 +299,63 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent dedupes by idempotencyKey after completion", { timeout: 8000 }, async () => {
|
test(
|
||||||
const { server, ws } = await startServerWithClient();
|
"agent dedupes by idempotencyKey after completion",
|
||||||
ws.send(
|
{ timeout: 8000 },
|
||||||
JSON.stringify({
|
async () => {
|
||||||
type: "hello",
|
const { server, ws } = await startServerWithClient();
|
||||||
minProtocol: 1,
|
ws.send(
|
||||||
maxProtocol: 1,
|
JSON.stringify({
|
||||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
type: "hello",
|
||||||
caps: [],
|
minProtocol: 1,
|
||||||
}),
|
maxProtocol: 1,
|
||||||
);
|
client: {
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
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");
|
const firstFinalP = onceMessage(
|
||||||
ws.send(
|
ws,
|
||||||
JSON.stringify({
|
(o) =>
|
||||||
type: "req",
|
o.type === "res" &&
|
||||||
id: "ag1",
|
o.id === "ag1" &&
|
||||||
method: "agent",
|
o.payload?.status !== "accepted",
|
||||||
params: { message: "hi", idempotencyKey: "same-agent" },
|
);
|
||||||
}),
|
ws.send(
|
||||||
);
|
JSON.stringify({
|
||||||
const firstFinal = await firstFinalP;
|
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");
|
const secondP = onceMessage(
|
||||||
ws.send(
|
ws,
|
||||||
JSON.stringify({
|
(o) => o.type === "res" && o.id === "ag2",
|
||||||
type: "req",
|
);
|
||||||
id: "ag2",
|
ws.send(
|
||||||
method: "agent",
|
JSON.stringify({
|
||||||
params: { message: "hi again", idempotencyKey: "same-agent" },
|
type: "req",
|
||||||
}),
|
id: "ag2",
|
||||||
);
|
method: "agent",
|
||||||
const second = await secondP;
|
params: { message: "hi again", idempotencyKey: "same-agent" },
|
||||||
expect(second.payload).toEqual(firstFinal.payload);
|
}),
|
||||||
|
);
|
||||||
|
const second = await secondP;
|
||||||
|
expect(second.payload).toEqual(firstFinal.payload);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
@@ -288,55 +364,75 @@ describe("gateway server", () => {
|
|||||||
type: "hello",
|
type: "hello",
|
||||||
minProtocol: 1,
|
minProtocol: 1,
|
||||||
maxProtocol: 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: [],
|
caps: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
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();
|
await server.close();
|
||||||
const evt = await shutdownP;
|
const evt = await shutdownP;
|
||||||
expect(evt.payload?.reason).toBeDefined();
|
expect(evt.payload?.reason).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
|
test(
|
||||||
const port = await getFreePort();
|
"presence broadcast reaches multiple clients",
|
||||||
const server = await startGatewayServer(port);
|
{ timeout: 8000 },
|
||||||
const mkClient = async () => {
|
async () => {
|
||||||
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
const port = await getFreePort();
|
||||||
await new Promise<void>((resolve) => c.once("open", resolve));
|
const server = await startGatewayServer(port);
|
||||||
c.send(
|
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({
|
JSON.stringify({
|
||||||
type: "hello",
|
type: "req",
|
||||||
minProtocol: 1,
|
id: "broadcast",
|
||||||
maxProtocol: 1,
|
method: "system-event",
|
||||||
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
params: { text: "fanout" },
|
||||||
caps: [],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await onceMessage(c, (o) => o.type === "hello-ok");
|
const events = await Promise.all(waits);
|
||||||
return c;
|
for (const evt of events) {
|
||||||
};
|
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof evt.seq).toBe("number");
|
||||||
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
|
}
|
||||||
const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"));
|
for (const c of clients) c.close();
|
||||||
clients[0].send(
|
await server.close();
|
||||||
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 () => {
|
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
@@ -345,7 +441,12 @@ describe("gateway server", () => {
|
|||||||
type: "hello",
|
type: "hello",
|
||||||
minProtocol: 1,
|
minProtocol: 1,
|
||||||
maxProtocol: 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: [],
|
caps: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -387,7 +488,12 @@ describe("gateway server", () => {
|
|||||||
type: "hello",
|
type: "hello",
|
||||||
minProtocol: 1,
|
minProtocol: 1,
|
||||||
maxProtocol: 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: [],
|
caps: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -397,7 +503,11 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
const idem = "reconnect-agent";
|
const idem = "reconnect-agent";
|
||||||
const ws1 = await dial();
|
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(
|
ws1.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "req",
|
type: "req",
|
||||||
@@ -410,7 +520,11 @@ describe("gateway server", () => {
|
|||||||
ws1.close();
|
ws1.close();
|
||||||
|
|
||||||
const ws2 = await dial();
|
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(
|
ws2.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "req",
|
type: "req",
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import os from "node:os";
|
|
||||||
import { randomUUID } from "node:crypto";
|
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 { getHealthSnapshot } from "../commands/health.js";
|
||||||
import { getStatusSummary } from "../commands/status.js";
|
import { getStatusSummary } from "../commands/status.js";
|
||||||
|
import { onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-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 { logError } from "../logger.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
type Hello,
|
|
||||||
type RequestFrame,
|
|
||||||
type Snapshot,
|
|
||||||
errorShape,
|
errorShape,
|
||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
|
type Hello,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
type RequestFrame,
|
||||||
|
type Snapshot,
|
||||||
validateAgentParams,
|
validateAgentParams,
|
||||||
validateHello,
|
validateHello,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
validateSendParams,
|
validateSendParams,
|
||||||
} from "./protocol/index.js";
|
} 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 = {
|
type Client = {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
@@ -71,21 +74,32 @@ const HANDSHAKE_TIMEOUT_MS = 3000;
|
|||||||
const TICK_INTERVAL_MS = 30_000;
|
const TICK_INTERVAL_MS = 30_000;
|
||||||
const DEDUPE_TTL_MS = 5 * 60_000;
|
const DEDUPE_TTL_MS = 5 * 60_000;
|
||||||
const DEDUPE_MAX = 1000;
|
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 dedupe = new Map<string, DedupeEntry>();
|
||||||
|
|
||||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||||
|
|
||||||
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
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 clients = new Set<Client>();
|
||||||
|
|
||||||
const broadcast = (
|
const broadcast = (
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
opts?: { dropIfSlow?: boolean; stateVersion?: { presence?: number; health?: number } },
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const frame = JSON.stringify({
|
const frame = JSON.stringify({
|
||||||
type: "event",
|
type: "event",
|
||||||
@@ -206,11 +220,14 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
const hello = parsed as Hello;
|
const hello = parsed as Hello;
|
||||||
// protocol negotiation
|
// protocol negotiation
|
||||||
const { minProtocol, maxProtocol } = hello;
|
const { minProtocol, maxProtocol } = hello;
|
||||||
if (maxProtocol < SERVER_PROTO || minProtocol > SERVER_PROTO) {
|
if (
|
||||||
|
maxProtocol < PROTOCOL_VERSION ||
|
||||||
|
minProtocol > PROTOCOL_VERSION
|
||||||
|
) {
|
||||||
send({
|
send({
|
||||||
type: "hello-error",
|
type: "hello-error",
|
||||||
reason: "protocol mismatch",
|
reason: "protocol mismatch",
|
||||||
expectedProtocol: SERVER_PROTO,
|
expectedProtocol: PROTOCOL_VERSION,
|
||||||
});
|
});
|
||||||
socket.close(1002, "protocol mismatch");
|
socket.close(1002, "protocol mismatch");
|
||||||
close();
|
close();
|
||||||
@@ -250,9 +267,12 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
snapshot.stateVersion.health = ++healthVersion;
|
snapshot.stateVersion.health = ++healthVersion;
|
||||||
const helloOk = {
|
const helloOk = {
|
||||||
type: "hello-ok",
|
type: "hello-ok",
|
||||||
protocol: SERVER_PROTO,
|
protocol: PROTOCOL_VERSION,
|
||||||
server: {
|
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,
|
commit: process.env.GIT_COMMIT,
|
||||||
host: os.hostname(),
|
host: os.hostname(),
|
||||||
connId,
|
connId,
|
||||||
@@ -284,11 +304,8 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const req = parsed as RequestFrame;
|
const req = parsed as RequestFrame;
|
||||||
const respond = (
|
const respond = (ok: boolean, payload?: unknown, error?: ErrorShape) =>
|
||||||
ok: boolean,
|
send({ type: "res", id: req.id, ok, payload, error });
|
||||||
payload?: unknown,
|
|
||||||
error?: ErrorShape,
|
|
||||||
) => send({ type: "res", id: req.id, ok, payload, error });
|
|
||||||
|
|
||||||
switch (req.method) {
|
switch (req.method) {
|
||||||
case "health": {
|
case "health": {
|
||||||
@@ -308,9 +325,15 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "system-event": {
|
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) {
|
if (!text) {
|
||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, "text required"),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
enqueueSystemEvent(text);
|
enqueueSystemEvent(text);
|
||||||
@@ -320,7 +343,10 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
{ presence: listSystemPresence() },
|
{ presence: listSystemPresence() },
|
||||||
{
|
{
|
||||||
dropIfSlow: true,
|
dropIfSlow: true,
|
||||||
stateVersion: { presence: presenceVersion, health: healthVersion },
|
stateVersion: {
|
||||||
|
presence: presenceVersion,
|
||||||
|
health: healthVersion,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
respond(true, { ok: true }, undefined);
|
respond(true, { ok: true }, undefined);
|
||||||
@@ -407,9 +433,14 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
}
|
}
|
||||||
const message = params.message.trim();
|
const message = params.message.trim();
|
||||||
const runId = params.sessionId || randomUUID();
|
const runId = params.sessionId || randomUUID();
|
||||||
const ackPayload = { runId, status: "accepted" as const };
|
// Acknowledge via event to avoid double res frames
|
||||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: ackPayload });
|
const ackEvent = {
|
||||||
respond(true, ackPayload, undefined); // ack quickly
|
type: "event",
|
||||||
|
event: "agent",
|
||||||
|
payload: { runId, status: "accepted" as const },
|
||||||
|
seq: ++seq,
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify(ackEvent));
|
||||||
try {
|
try {
|
||||||
await agentCommand(
|
await agentCommand(
|
||||||
{
|
{
|
||||||
@@ -423,19 +454,43 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
const payload = { runId, status: "ok" as const, summary: "completed" };
|
const payload = {
|
||||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload });
|
runId,
|
||||||
|
status: "ok" as const,
|
||||||
|
summary: "completed",
|
||||||
|
};
|
||||||
|
dedupe.set(`agent:${idem}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: true,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
respond(true, payload, undefined);
|
respond(true, payload, undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||||
const payload = { runId, status: "error" as const, summary: String(err) };
|
const payload = {
|
||||||
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: false, payload, error });
|
runId,
|
||||||
|
status: "error" as const,
|
||||||
|
summary: String(err),
|
||||||
|
};
|
||||||
|
dedupe.set(`agent:${idem}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: false,
|
||||||
|
payload,
|
||||||
|
error,
|
||||||
|
});
|
||||||
respond(false, payload, error);
|
respond(false, payload, error);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`unknown method: ${req.method}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,7 +510,10 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
close: async () => {
|
close: async () => {
|
||||||
broadcast("shutdown", { reason: "gateway stopping", restartExpectedMs: null });
|
broadcast("shutdown", {
|
||||||
|
reason: "gateway stopping",
|
||||||
|
restartExpectedMs: null,
|
||||||
|
});
|
||||||
clearInterval(tickInterval);
|
clearInterval(tickInterval);
|
||||||
clearInterval(dedupeCleanup);
|
clearInterval(dedupeCleanup);
|
||||||
if (agentUnsub) {
|
if (agentUnsub) {
|
||||||
|
|||||||
@@ -108,10 +108,7 @@ export function updateSystemPresence(text: string) {
|
|||||||
entries.set(key, parsed);
|
entries.set(key, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertPresence(
|
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
|
||||||
key: string,
|
|
||||||
presence: Partial<SystemPresence>,
|
|
||||||
) {
|
|
||||||
ensureSelfPresence();
|
ensureSelfPresence();
|
||||||
const existing = entries.get(key) ?? ({} as SystemPresence);
|
const existing = entries.get(key) ?? ({} as SystemPresence);
|
||||||
const merged: SystemPresence = {
|
const merged: SystemPresence = {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import type { AddressInfo } from "node:net";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import {
|
import {
|
||||||
|
__forceWebChatSnapshotForTests,
|
||||||
startWebChatServer,
|
startWebChatServer,
|
||||||
stopWebChatServer,
|
stopWebChatServer,
|
||||||
__forceWebChatSnapshotForTests,
|
|
||||||
__broadcastGatewayEventForTests,
|
|
||||||
} from "./server.js";
|
} from "./server.js";
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
async function getFreePort(): Promise<number> {
|
||||||
@@ -12,80 +12,83 @@ async function getFreePort(): Promise<number> {
|
|||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
server.listen(0, "127.0.0.1", () => {
|
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)));
|
server.close((err: Error | null) => (err ? reject(err) : resolve(port)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 8000) {
|
type SnapshotMessage = {
|
||||||
return new Promise<T>((resolve, reject) => {
|
type?: string;
|
||||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
snapshot?: { stateVersion?: { presence?: number } };
|
||||||
const closeHandler = (code: number, reason: Buffer) => {
|
};
|
||||||
clearTimeout(timer);
|
type SessionMessage = { type?: string };
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("webchat server", () => {
|
describe("webchat server", () => {
|
||||||
test("hydrates snapshot to new sockets (offline mock)", { timeout: 8000 }, async () => {
|
test(
|
||||||
const wPort = await getFreePort();
|
"hydrates snapshot to new sockets (offline mock)",
|
||||||
await startWebChatServer(wPort, undefined, { disableGateway: true });
|
{ timeout: 8000 },
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${wPort}/webchat/socket?session=test`);
|
async () => {
|
||||||
const messages: any[] = [];
|
const wPort = await getFreePort();
|
||||||
ws.on("message", (data) => {
|
await startWebChatServer(wPort, undefined, { disableGateway: true });
|
||||||
try {
|
const ws = new WebSocket(
|
||||||
messages.push(JSON.parse(String(data)));
|
`ws://127.0.0.1:${wPort}/webchat/socket?session=test`,
|
||||||
} catch {
|
);
|
||||||
/* ignore */
|
const messages: unknown[] = [];
|
||||||
}
|
ws.on("message", (data) => {
|
||||||
});
|
try {
|
||||||
|
messages.push(JSON.parse(String(data)));
|
||||||
try {
|
} catch {
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
/* ignore */
|
||||||
|
}
|
||||||
__forceWebChatSnapshotForTests({
|
|
||||||
presence: [],
|
|
||||||
health: {},
|
|
||||||
stateVersion: { presence: 1, health: 1 },
|
|
||||||
uptimeMs: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const waitFor = async (pred: (m: any) => boolean, label: string) => {
|
try {
|
||||||
const start = Date.now();
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
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}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
await waitFor((m) => m?.type === "session", "session");
|
__forceWebChatSnapshotForTests({
|
||||||
const snap = await waitFor((m) => m?.type === "gateway-snapshot", "snapshot");
|
presence: [],
|
||||||
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
|
health: {},
|
||||||
} finally {
|
stateVersion: { presence: 1, health: 1 },
|
||||||
ws.close();
|
uptimeMs: 0,
|
||||||
await stopWebChatServer();
|
});
|
||||||
}
|
|
||||||
});
|
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 fs from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { type WebSocket, WebSocketServer } from "ws";
|
import { type WebSocket, WebSocketServer } from "ws";
|
||||||
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { logDebug, logError } from "../logger.js";
|
|
||||||
import { GatewayClient } from "../gateway/client.js";
|
import { GatewayClient } from "../gateway/client.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { logDebug, logError } from "../logger.js";
|
||||||
|
|
||||||
const WEBCHAT_DEFAULT_PORT = 18788;
|
const WEBCHAT_DEFAULT_PORT = 18788;
|
||||||
|
|
||||||
@@ -338,10 +337,20 @@ export async function startWebChatServer(
|
|||||||
gatewayReady = true;
|
gatewayReady = true;
|
||||||
latestSnapshot = hello.snapshot as Record<string, unknown>;
|
latestSnapshot = hello.snapshot as Record<string, unknown>;
|
||||||
latestPolicy = hello.policy 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) => {
|
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: () => {
|
onClose: () => {
|
||||||
gatewayReady = false;
|
gatewayReady = false;
|
||||||
@@ -517,10 +526,17 @@ export function __forceWebChatSnapshotForTests(
|
|||||||
latestSnapshot = snapshot;
|
latestSnapshot = snapshot;
|
||||||
latestPolicy = policy ?? null;
|
latestPolicy = policy ?? null;
|
||||||
gatewayReady = true;
|
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 });
|
broadcastAll({ type: "gateway-event", event, payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user