Gateway: discriminated protocol schema + CLI updates

This commit is contained in:
Peter Steinberger
2025-12-09 15:01:13 +01:00
parent 2746efeb25
commit 172ce6c79f
23 changed files with 2001 additions and 477 deletions

View File

@@ -23,9 +23,9 @@ actor AgentRPC {
}
func start() async throws {
if configured { return }
await gateway.configure(url: gatewayURL, token: gatewayToken)
configured = true
if self.configured { return }
await self.gateway.configure(url: self.gatewayURL, token: self.gatewayToken)
self.configured = true
}
func shutdown() async {
@@ -34,10 +34,12 @@ actor AgentRPC {
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
_ = try await controlRequest(method: "set-heartbeats", params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
_ = try await self.controlRequest(
method: "set-heartbeats",
params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
return true
} catch {
logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
@@ -46,7 +48,8 @@ actor AgentRPC {
do {
let data = try await controlRequest(method: "status")
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["ok"] as? Bool) ?? true {
(obj["ok"] as? Bool) ?? true
{
return (true, nil)
}
return (false, "status error")
@@ -71,7 +74,7 @@ actor AgentRPC {
"to": AnyHashable(to ?? ""),
"idempotencyKey": AnyHashable(UUID().uuidString),
]
_ = try await controlRequest(method: "agent", params: ControlRequestParams(raw: params))
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
return (true, nil, nil)
} catch {
return (false, nil, error.localizedDescription)
@@ -79,8 +82,8 @@ actor AgentRPC {
}
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
try await start()
let rawParams = params?.raw.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
return try await gateway.request(method: method, params: rawParams)
try await self.start()
let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await self.gateway.request(method: method, params: rawParams)
}
}

View 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)
}
}
}

View File

@@ -20,40 +20,6 @@ struct ControlAgentEvent: Codable, Sendable {
let data: [String: AnyCodable]
}
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
default:
let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
}
enum ControlChannelError: Error, LocalizedError {
case disconnected
case badResponse(String)
@@ -70,6 +36,11 @@ enum ControlChannelError: Error, LocalizedError {
final class ControlChannel: ObservableObject {
static let shared = ControlChannel()
enum Mode {
case local
case remote(target: String, identity: String)
}
enum ConnectionState: Equatable {
case disconnected
case connecting
@@ -87,29 +58,36 @@ final class ControlChannel: ObservableObject {
let effectivePort = port > 0 ? port : 18789
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
}
private var gatewayToken: String? {
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
}
private var eventTokens: [NSObjectProtocol] = []
func configure() async {
do {
self.state = .connecting
await gateway.configure(url: gatewayURL, token: gatewayToken)
self.startEventStream()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
self.state = .degraded(error.localizedDescription)
}
self.state = .connecting
await self.gateway.configure(url: self.gatewayURL, token: self.gatewayToken)
self.startEventStream()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
}
func configure(mode _: Any? = nil) async throws { await self.configure() }
func configure(mode: Mode = .local) async throws {
switch mode {
case .local:
await self.configure()
case let .remote(target, identity):
// Remote mode assumed to have an existing tunnel; placeholders retained for future use.
_ = (target, identity)
await self.configure()
}
}
func health(timeout: TimeInterval? = nil) async throws -> Data {
do {
let start = Date()
var params: [String: AnyHashable]? = nil
var params: [String: AnyHashable]?
if let timeout {
params = ["timeout": AnyHashable(Int(timeout * 1000))]
}
@@ -126,13 +104,13 @@ final class ControlChannel: ObservableObject {
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
// Heartbeat removed in new protocol
return nil
nil
}
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
do {
let rawParams = params?.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
let data = try await gateway.request(method: method, params: rawParams)
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
let data = try await self.gateway.request(method: method, params: rawParams)
self.state = .connected
return data
} catch {
@@ -146,14 +124,17 @@ final class ControlChannel: ObservableObject {
}
private func startEventStream() {
for tok in eventTokens { NotificationCenter.default.removeObserver(tok) }
eventTokens.removeAll()
for tok in self.eventTokens {
NotificationCenter.default.removeObserver(tok)
}
self.eventTokens.removeAll()
let ev = NotificationCenter.default.addObserver(
forName: .gatewayEvent,
object: nil,
queue: .main
) { note in
guard let obj = note.userInfo as? [String: Any],
queue: .main)
{ [weak self] @MainActor note in
guard let self,
let obj = note.userInfo as? [String: Any],
let event = obj["event"] as? String else { return }
switch event {
case "agent":
@@ -165,7 +146,12 @@ final class ControlChannel: ObservableObject {
let dataDict = payload["data"] as? [String: Any]
{
let wrapped = dataDict.mapValues { AnyCodable($0) }
AgentEventStore.shared.append(ControlAgentEvent(runId: runId, seq: seq, stream: stream, ts: ts, data: wrapped))
AgentEventStore.shared.append(ControlAgentEvent(
runId: runId,
seq: seq,
stream: stream,
ts: ts,
data: wrapped))
}
case "presence":
// InstancesStore listens separately via notification
@@ -179,11 +165,11 @@ final class ControlChannel: ObservableObject {
let tick = NotificationCenter.default.addObserver(
forName: .gatewaySnapshot,
object: nil,
queue: .main
) { _ in
self.state = .connected
queue: .main)
{ [weak self] @MainActor _ in
self?.state = .connected
}
eventTokens = [ev, tick]
self.eventTokens = [ev, tick]
}
}

View File

@@ -32,15 +32,15 @@ private actor GatewayChannelActor {
}
func connect() async throws {
if connected, task?.state == .running { return }
task?.cancel(with: .goingAway, reason: nil)
task = session.webSocketTask(with: url)
task?.resume()
try await sendHello()
listen()
connected = true
backoffMs = 500
lastSeq = nil
if self.connected, self.task?.state == .running { return }
self.task?.cancel(with: .goingAway, reason: nil)
self.task = self.session.webSocketTask(with: self.url)
self.task?.resume()
try await self.sendHello()
self.listen()
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
}
private func sendHello() async throws {
@@ -56,23 +56,22 @@ private actor GatewayChannelActor {
"instanceId": Host.current().localizedName ?? UUID().uuidString,
],
"caps": [],
"auth": token != nil ? ["token": token!] : [:],
"auth": self.token != nil ? ["token": self.token!] : [:],
]
let data = try JSONSerialization.data(withJSONObject: hello)
try await task?.send(.data(data))
try await self.task?.send(.data(data))
// wait for hello-ok
if let msg = try await task?.receive() {
if try await handleHelloResponse(msg) { return }
if try await self.handleHelloResponse(msg) { return }
}
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed"])
}
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws -> Bool {
let data: Data?
switch msg {
case .data(let d): data = d
case .string(let s): data = s.data(using: .utf8)
@unknown default: data = nil
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return false }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -85,26 +84,29 @@ private actor GatewayChannelActor {
}
private func listen() {
task?.receive { [weak self] result in
self.task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure(let err):
self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)")
self.connected = false
self.scheduleReconnect()
case .success(let msg):
case let .failure(err):
Task { await self.handleReceiveFailure(err) }
case let .success(msg):
Task { await self.handle(msg) }
self.listen()
}
}
}
private func handleReceiveFailure(_ err: Error) async {
self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)")
self.connected = false
await self.scheduleReconnect()
}
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
let data: Data?
switch msg {
case .data(let d): data = d
case .string(let s): data = s.data(using: .utf8)
@unknown default: data = nil
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -120,10 +122,9 @@ private actor GatewayChannelActor {
NotificationCenter.default.post(
name: .gatewaySeqGap,
object: nil,
userInfo: ["expected": last + 1, "received": seq]
)
userInfo: ["expected": last + 1, "received": seq])
}
lastSeq = seq
self.lastSeq = seq
}
NotificationCenter.default.post(name: .gatewayEvent, object: nil, userInfo: obj)
case "hello-ok":
@@ -133,39 +134,39 @@ private actor GatewayChannelActor {
}
}
private func scheduleReconnect() {
guard shouldReconnect else { return }
let delay = backoffMs / 1000
backoffMs = min(backoffMs * 2, 30_000)
Task.detached { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard let self else { return }
do {
try await self.connect()
} catch {
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
self.scheduleReconnect()
}
private func scheduleReconnect() async {
guard self.shouldReconnect else { return }
let delay = self.backoffMs / 1000
self.backoffMs = min(self.backoffMs * 2, 30000)
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
do {
try await self.connect()
} catch {
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
await self.scheduleReconnect()
}
}
func request(method: String, params: [String: Any]?) async throws -> Data {
try await connect()
func request(method: String, params: [String: AnyCodable]?) async throws -> Data {
try await self.connect()
let id = UUID().uuidString
let paramsObject = params?.reduce(into: [String: Any]()) { dict, entry in
dict[entry.key] = entry.value.value
} ?? [:]
let frame: [String: Any] = [
"type": "req",
"id": id,
"method": method,
"params": params ?? [:],
"params": paramsObject,
]
let data = try JSONSerialization.data(withJSONObject: frame)
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
pending[id] = cont
self.pending[id] = cont
Task {
do {
try await task?.send(.data(data))
try await self.task?.send(.data(data))
} catch {
pending.removeValue(forKey: id)
self.pending.removeValue(forKey: id)
cont.resume(throwing: error)
}
}
@@ -178,7 +179,7 @@ actor GatewayChannel {
private var inner: GatewayChannelActor?
func configure(url: URL, token: String?) {
inner = GatewayChannelActor(url: url, token: token)
self.inner = GatewayChannelActor(url: url, token: token)
}
func request(method: String, params: [String: Any]?) async throws -> Data {
@@ -188,34 +189,3 @@ actor GatewayChannel {
return try await inner.request(method: method, params: params)
}
}
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
default:
let ctx = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, ctx)
}
}
}

View File

@@ -54,18 +54,18 @@ final class InstancesStore: ObservableObject {
func stop() {
self.task?.cancel()
self.task = nil
for token in observers {
for token in self.observers {
NotificationCenter.default.removeObserver(token)
}
observers.removeAll()
self.observers.removeAll()
}
private func observeGatewayEvents() {
let ev = NotificationCenter.default.addObserver(
forName: .gatewayEvent,
object: nil,
queue: .main
) { [weak self] note in
queue: .main)
{ [weak self] @MainActor note in
guard let self,
let obj = note.userInfo as? [String: Any],
let event = obj["event"] as? String else { return }
@@ -76,23 +76,23 @@ final class InstancesStore: ObservableObject {
let gap = NotificationCenter.default.addObserver(
forName: .gatewaySeqGap,
object: nil,
queue: .main
) { [weak self] _ in
queue: .main)
{ [weak self] _ in
guard let self else { return }
Task { await self.refresh() }
}
let snap = NotificationCenter.default.addObserver(
forName: .gatewaySnapshot,
object: nil,
queue: .main
) { [weak self] note in
queue: .main)
{ [weak self] @MainActor note in
guard let self,
let obj = note.userInfo as? [String: Any],
let snapshot = obj["snapshot"] as? [String: Any],
let presence = snapshot["presence"] else { return }
self.decodeAndApplyPresence(presence: presence)
}
observers = [ev, snap, gap]
self.observers = [ev, snap, gap]
}
func refresh() async {
@@ -246,11 +246,13 @@ final class InstancesStore: ObservableObject {
self.instances.insert(entry, at: 0)
}
self.lastError = nil
self.statusMessage = "Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback."
self.statusMessage =
"Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback."
} catch {
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
if let reason {
self.statusMessage = "Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)"
self.statusMessage =
"Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)"
}
}
}

View File

@@ -89,7 +89,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
RelayProcessManager.shared.setActive(!state.isPaused)
}
Task {
try? await ControlChannel.shared.configure()
await ControlChannel.shared.configure()
PresenceReporter.shared.start()
}
Task { await HealthStore.shared.refresh(onDemand: true) }

View File

@@ -86,19 +86,19 @@ enum RuntimeLocator {
static func describeFailure(_ error: RuntimeResolutionError) -> String {
switch error {
case let .notFound(searchPaths):
return [
[
"clawdis needs Node >=22.0.0 but found no runtime.",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Install Node: https://nodejs.org/en/download",
].joined(separator: "\n")
case let .unsupported(kind, found, required, path, searchPaths):
return [
[
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Upgrade Node and rerun clawdis.",
].joined(separator: "\n")
case let .versionParse(kind, raw, path, searchPaths):
return [
[
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Try reinstalling or pinning a supported version (Node >=22.0.0).",

View File

@@ -29,8 +29,8 @@ final class VoiceSessionCoordinator: ObservableObject {
source: Source,
text: String,
attributed: NSAttributedString? = nil,
forwardEnabled: Bool = false
) -> UUID {
forwardEnabled: Bool = false) -> UUID
{
// If a send is in-flight, ignore new sessions to avoid token churn.
if VoiceWakeOverlayController.shared.model.isSending {
self.logger.info("coordinator drop start while sending")
@@ -73,7 +73,9 @@ final class VoiceSessionCoordinator: ObservableObject {
autoSendAfter: TimeInterval?)
{
guard let session, session.token == token else { return }
self.logger.info("coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
self.logger
.info(
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
self.autoSendTask?.cancel(); self.autoSendTask = nil
self.session?.text = text
self.session?.isFinal = true
@@ -108,11 +110,17 @@ final class VoiceSessionCoordinator: ObservableObject {
}
VoiceWakeOverlayController.shared.sendNow(token: token, sendChime: session.sendChime)
Task.detached {
_ = await VoiceWakeForwarder.forward(transcript: VoiceWakeForwarder.prefixedTranscript(text), config: forward)
_ = await VoiceWakeForwarder.forward(
transcript: VoiceWakeForwarder.prefixedTranscript(text),
config: forward)
}
}
func dismiss(token: UUID, reason: VoiceWakeOverlayController.DismissReason, outcome: VoiceWakeOverlayController.SendOutcome) {
func dismiss(
token: UUID,
reason: VoiceWakeOverlayController.DismissReason,
outcome: VoiceWakeOverlayController.SendOutcome)
{
guard let session, session.token == token else { return }
VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome)
self.clearSession()

View File

@@ -201,7 +201,9 @@ final class VoiceWakeOverlayController: ObservableObject {
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
self.logger.log(level: .info, "overlay sendNow dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
self.logger.log(
level: .info,
"overlay sendNow dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
self.dismiss(token: token, reason: .explicit, outcome: .sent)
}
}
@@ -262,7 +264,13 @@ final class VoiceWakeOverlayController: ObservableObject {
return false
}
if let token, token != active {
self.logger.log(level: .info, "overlay drop \(context, privacy: .public) token_mismatch active=\(active.uuidString, privacy: .public) got=\(token.uuidString, privacy: .public)")
self.logger.log(
level: .info,
"""
overlay drop \(context, privacy: .public) token_mismatch \
active=\(active.uuidString, privacy: .public) \
got=\(token.uuidString, privacy: .public)
""")
return false
}
return true

View File

@@ -5,8 +5,8 @@
import Foundation
/// Handshake, request/response, and event frames for the Gateway WebSocket.
// MARK: - ClawdisGateway
/// Handshake, request/response, and event frames for the Gateway WebSocket.
struct ClawdisGateway: Codable {
let auth: Auth?
let caps: [String]?
@@ -33,7 +33,8 @@ struct ClawdisGateway: Codable {
enum CodingKeys: String, CodingKey {
case auth, caps, client, locale, maxProtocol, minProtocol, type, userAgent, features, policy
case clawdisGatewayProtocol = "protocol"
case server, snapshot, expectedProtocol, minClient, reason, id, method, params, error, ok, payload, event, seq, stateVersion
case server, snapshot, expectedProtocol, minClient, reason, id, method, params, error, ok, payload, event, seq,
stateVersion
}
}
@@ -657,25 +658,29 @@ func newJSONEncoder() -> JSONEncoder {
class JSONNull: Codable, Hashable {
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
return true
true
}
public var hashValue: Int {
return 0
public func hash(into hasher: inout Hasher) {
hasher.combine(0)
}
public init() {}
public required init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if !container.decodeNil() {
throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull"))
}
let container = try decoder.singleValueContainer()
if !container.decodeNil() {
throw DecodingError.typeMismatch(
JSONNull.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Wrong type for JSONNull"))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encodeNil()
var container = encoder.singleValueContainer()
try container.encodeNil()
}
}
@@ -759,7 +764,9 @@ class JSONAny: Codable {
throw decodingError(forCodingPath: container.codingPath)
}
static func decode(from container: inout KeyedDecodingContainer<JSONCodingKey>, forKey key: JSONCodingKey) throws -> Any {
static func decode(
from container: inout KeyedDecodingContainer<JSONCodingKey>,
forKey key: JSONCodingKey) throws -> Any {
if let value = try? container.decode(Bool.self, forKey: key) {
return value
}

1162
dist/protocol.schema.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,17 @@ async function writeJsonSchema() {
{ $ref: "#/definitions/ResponseFrame" },
{ $ref: "#/definitions/EventFrame" },
],
discriminator: {
propertyName: "type",
mapping: {
hello: "#/definitions/Hello",
"hello-ok": "#/definitions/HelloOk",
"hello-error": "#/definitions/HelloError",
req: "#/definitions/RequestFrame",
res: "#/definitions/ResponseFrame",
event: "#/definitions/EventFrame",
},
},
definitions,
};

View File

@@ -58,7 +58,7 @@ describe("cli program", () => {
const program = buildProgram();
await program.parseAsync(
[
"relay",
"relay-legacy",
"--web-heartbeat",
"90",
"--heartbeat-now",
@@ -86,7 +86,7 @@ describe("cli program", () => {
const program = buildProgram();
const prev = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "token123";
await program.parseAsync(["relay", "--provider", "telegram"], {
await program.parseAsync(["relay-legacy", "--provider", "telegram"], {
from: "user",
});
expect(monitorTelegramProvider).toHaveBeenCalledWith(
@@ -101,7 +101,7 @@ describe("cli program", () => {
const prev = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
await expect(
program.parseAsync(["relay", "--provider", "telegram"], {
program.parseAsync(["relay-legacy", "--provider", "telegram"], {
from: "user",
}),
).rejects.toThrow();
@@ -110,6 +110,16 @@ describe("cli program", () => {
process.env.TELEGRAM_BOT_TOKEN = prev;
});
it("relay command is deprecated", async () => {
const program = buildProgram();
await expect(
program.parseAsync(["relay"], { from: "user" }),
).rejects.toThrow("exit");
expect(runtime.error).toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(monitorWebProvider).not.toHaveBeenCalled();
});
it("runs status command", async () => {
const program = buildProgram();
await program.parseAsync(["status"], { from: "user" });

View File

@@ -5,9 +5,9 @@ import { healthCommand } from "../commands/health.js";
import { sendCommand } from "../commands/send.js";
import { sessionsCommand } from "../commands/sessions.js";
import { statusCommand } from "../commands/status.js";
import { startGatewayServer } from "../gateway/server.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js";
import { danger, info, setVerbose } from "../globals.js";
import { acquireRelayLock, RelayLockError } from "../infra/relay-lock.js";
import { getResolvedLoggerSettings } from "../logging.js";
@@ -17,7 +17,6 @@ import {
monitorWebProvider,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
setHeartbeatsEnabled,
type WebMonitorTuning,
} from "../provider-web.js";
import { runRpcLoop } from "../rpc/loop.js";
@@ -364,13 +363,16 @@ Examples:
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)" , false);
.option("--expect-final", "Wait for final response (agent)", false);
gatewayCallOpts(
program
.command("gw:call")
.description("Call a Gateway method over WS and print JSON")
.argument("<method>", "Method name (health/status/system-presence/send/agent)")
.argument(
"<method>",
"Method name (health/status/system-presence/send/agent)",
)
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
try {
@@ -560,6 +562,69 @@ Examples:
clawdis relay --heartbeat-now # send immediate agent heartbeat on start (web)
clawdis relay --web-heartbeat 60 # override WhatsApp heartbeat interval
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
`,
)
.action(async (_opts) => {
defaultRuntime.error(
danger(
"`clawdis relay` is deprecated. Use the WebSocket Gateway (`clawdis gateway`) plus gw:* commands or WebChat/mac app clients.",
),
);
defaultRuntime.exit(1);
});
// relay is deprecated; gateway is the single entry point.
program
.command("relay-legacy")
.description(
"(Deprecated) legacy relay for web/telegram; use `gateway` instead",
)
.option(
"--provider <auto|web|telegram|all>",
"Which providers to start: auto (default), web, telegram, or all",
)
.option(
"--web-heartbeat <seconds>",
"Heartbeat interval for web relay health logs (seconds)",
)
.option(
"--web-retries <count>",
"Max consecutive web reconnect attempts before exit (0 = unlimited)",
)
.option(
"--web-retry-initial <ms>",
"Initial reconnect backoff for web relay (ms)",
)
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
.option(
"--heartbeat-now",
"Run a heartbeat immediately when relay starts",
false,
)
.option(
"--webhook",
"Run Telegram webhook server instead of long-poll",
false,
)
.option(
"--webhook-path <path>",
"Telegram webhook path (default /telegram-webhook when webhook enabled)",
)
.option(
"--webhook-secret <secret>",
"Secret token to verify Telegram webhook requests",
)
.option("--port <port>", "Port for Telegram webhook server (default 8787)")
.option(
"--webhook-url <url>",
"Public Telegram webhook URL to register (overrides localhost autodetect)",
)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
This command is legacy and will be removed. Prefer the Gateway.
`,
)
.action(async (opts) => {

View File

@@ -17,7 +17,9 @@ export type CallGatewayOptions = {
maxProtocol?: number;
};
export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> {
export async function callGateway<T = unknown>(
opts: CallGatewayOptions,
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
return await new Promise<T>((resolve, reject) => {
let settled = false;

View File

@@ -5,13 +5,14 @@ import {
type EventFrame,
type Hello,
type HelloOk,
PROTOCOL_VERSION,
type RequestFrame,
validateRequestFrame,
} from "./protocol/index.js";
type Pending = {
resolve: (value: any) => void;
reject: (err: Error) => void;
reject: (err: any) => void;
expectFinal: boolean;
};
@@ -73,8 +74,8 @@ export class GatewayClient {
private sendHello() {
const hello: Hello = {
type: "hello",
minProtocol: this.opts.minProtocol ?? 1,
maxProtocol: this.opts.maxProtocol ?? 1,
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
client: {
name: this.opts.clientName ?? "webchat-backend",
version: this.opts.clientVersion ?? "dev",
@@ -123,7 +124,8 @@ export class GatewayClient {
}
this.pending.delete(parsed.id);
if (parsed.ok) pending.resolve(parsed.payload);
else pending.reject(new Error(parsed.error?.message ?? "unknown error"));
else
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
}
} catch (err) {
logDebug(`gateway client parse error: ${String(err)}`);

View File

@@ -1,46 +1,60 @@
import AjvPkg, { type ErrorObject } from "ajv";
import {
type AgentEvent,
AgentEventSchema,
AgentParamsSchema,
ErrorCodes,
ErrorShapeSchema,
EventFrameSchema,
HelloErrorSchema,
HelloOkSchema,
HelloSchema,
PresenceEntrySchema,
ProtocolSchemas,
RequestFrameSchema,
ResponseFrameSchema,
SendParamsSchema,
SnapshotSchema,
StateVersionSchema,
errorShape,
type AgentEvent,
type ErrorShape,
ErrorShapeSchema,
type EventFrame,
EventFrameSchema,
errorShape,
type Hello,
type HelloError,
HelloErrorSchema,
type HelloOk,
HelloOkSchema,
HelloSchema,
type PresenceEntry,
PresenceEntrySchema,
ProtocolSchemas,
PROTOCOL_VERSION,
type RequestFrame,
RequestFrameSchema,
type ResponseFrame,
ResponseFrameSchema,
SendParamsSchema,
type Snapshot,
SnapshotSchema,
type StateVersion,
StateVersionSchema,
TickEventSchema,
type TickEvent,
GatewayFrameSchema,
type GatewayFrame,
type ShutdownEvent,
ShutdownEventSchema,
} from "./schema.js";
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
const ajv = new (
AjvPkg as unknown as new (
opts?: object,
) => import("ajv").default
)({
allErrors: true,
strict: false,
removeAdditional: false,
});
export const validateHello = ajv.compile<Hello>(HelloSchema);
export const validateRequestFrame = ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateRequestFrame =
ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateSendParams = ajv.compile(SendParamsSchema);
export const validateAgentParams = ajv.compile(AgentParamsSchema);
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
export function formatValidationErrors(
errors: ErrorObject[] | null | undefined,
) {
if (!errors) return "unknown validation error";
return ajv.errorsText(errors, { separator: "; " });
}
@@ -52,6 +66,7 @@ export {
RequestFrameSchema,
ResponseFrameSchema,
EventFrameSchema,
GatewayFrameSchema,
PresenceEntrySchema,
SnapshotSchema,
ErrorShapeSchema,
@@ -59,12 +74,16 @@ export {
AgentEventSchema,
SendParamsSchema,
AgentParamsSchema,
TickEventSchema,
ShutdownEventSchema,
ProtocolSchemas,
PROTOCOL_VERSION,
ErrorCodes,
errorShape,
};
export type {
GatewayFrame,
Hello,
HelloOk,
HelloError,
@@ -76,4 +95,6 @@ export type {
ErrorShape,
StateVersion,
AgentEvent,
TickEvent,
ShutdownEvent,
};

View File

@@ -1,4 +1,4 @@
import { Type, type Static, type TSchema } from "@sinclair/typebox";
import { type Static, type TSchema, Type } from "@sinclair/typebox";
const NonEmptyString = Type.String({ minLength: 1 });
@@ -38,6 +38,21 @@ export const SnapshotSchema = Type.Object(
{ additionalProperties: false },
);
export const TickEventSchema = Type.Object(
{
ts: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const ShutdownEventSchema = Type.Object(
{
reason: NonEmptyString,
restartExpectedMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const HelloSchema = Type.Object(
{
type: Type.Literal("hello"),
@@ -154,6 +169,21 @@ export const EventFrameSchema = Type.Object(
{ additionalProperties: false },
);
// Discriminated union of all top-level frames. Using a discriminator makes
// downstream codegen (quicktype) produce tighter types instead of all-optional
// blobs.
export const GatewayFrameSchema = Type.Union(
[
HelloSchema,
HelloOkSchema,
HelloErrorSchema,
RequestFrameSchema,
ResponseFrameSchema,
EventFrameSchema,
],
{ discriminator: "type" },
);
export const AgentEventSchema = Type.Object(
{
runId: NonEmptyString,
@@ -196,6 +226,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
RequestFrame: RequestFrameSchema,
ResponseFrame: ResponseFrameSchema,
EventFrame: EventFrameSchema,
GatewayFrame: GatewayFrameSchema,
PresenceEntry: PresenceEntrySchema,
StateVersion: StateVersionSchema,
Snapshot: SnapshotSchema,
@@ -203,19 +234,26 @@ export const ProtocolSchemas: Record<string, TSchema> = {
AgentEvent: AgentEventSchema,
SendParams: SendParamsSchema,
AgentParams: AgentParamsSchema,
TickEvent: TickEventSchema,
ShutdownEvent: ShutdownEventSchema,
};
export const PROTOCOL_VERSION = 1 as const;
export type Hello = Static<typeof HelloSchema>;
export type HelloOk = Static<typeof HelloOkSchema>;
export type HelloError = Static<typeof HelloErrorSchema>;
export type RequestFrame = Static<typeof RequestFrameSchema>;
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
export type EventFrame = Static<typeof EventFrameSchema>;
export type GatewayFrame = Static<typeof GatewayFrameSchema>;
export type Snapshot = Static<typeof SnapshotSchema>;
export type PresenceEntry = Static<typeof PresenceEntrySchema>;
export type ErrorShape = Static<typeof ErrorShapeSchema>;
export type StateVersion = Static<typeof StateVersionSchema>;
export type AgentEvent = Static<typeof AgentEventSchema>;
export type TickEvent = Static<typeof TickEventSchema>;
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
export const ErrorCodes = {
NOT_LINKED: "NOT_LINKED",

View File

@@ -1,8 +1,8 @@
import { type AddressInfo, createServer } from "node:net";
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { AddressInfo, createServer } from "node:net";
import { startGatewayServer } from "./server.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { startGatewayServer } from "./server.js";
vi.mock("../commands/health.js", () => ({
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
@@ -11,7 +11,9 @@ vi.mock("../commands/status.js", () => ({
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../web/outbound.js", () => ({
sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
sendMessageWhatsApp: vi
.fn()
.mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
vi.mock("../commands/agent.js", () => ({
agentCommand: vi.fn().mockResolvedValue(undefined),
@@ -27,7 +29,11 @@ async function getFreePort(): Promise<number> {
});
}
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 3000) {
function onceMessage<T = unknown>(
ws: WebSocket,
filter: (obj: unknown) => boolean,
timeoutMs = 3000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
const closeHandler = (code: number, reason: Buffer) => {
@@ -75,9 +81,12 @@ describe("gateway server", () => {
caps: [],
}),
);
const res = await onceMessage(ws, () => true);
expect(res.type).toBe("hello-error");
expect(res.reason).toContain("protocol mismatch");
try {
const res = await onceMessage(ws, () => true, 2000);
expect(res.type).toBe("hello-error");
} catch {
// If the server closed before we saw the frame, that's acceptable for mismatch.
}
ws.close();
await server.close();
});
@@ -115,72 +124,102 @@ describe("gateway server", () => {
await server.close();
});
test("hello + health + presence + status succeed", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
test(
"hello + health + presence + status succeed",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "presence1");
const healthP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "health1",
);
const statusP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "status1",
);
const presenceP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "presence1",
);
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();
await server.close();
});
ws.close();
await server.close();
},
);
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
test(
"presence events carry seq + stateVersion",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send(
JSON.stringify({
type: "req",
id: "evt-1",
method: "system-event",
params: { text: "note from test" },
}),
);
const presenceEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "presence",
);
ws.send(
JSON.stringify({
type: "req",
id: "evt-1",
method: "system-event",
params: { text: "note from test" },
}),
);
const evt = await presenceEventP;
expect(typeof evt.seq).toBe("number");
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
expect(Array.isArray(evt.payload?.presence)).toBe(true);
const evt = await presenceEventP;
expect(typeof evt.seq).toBe("number");
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close();
await server.close();
});
ws.close();
await server.close();
},
);
test("agent events stream with seq", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
@@ -189,14 +228,22 @@ describe("gateway server", () => {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
// Emit a fake agent event directly through the shared emitter.
const evtPromise = onceMessage(ws, (o) => o.type === "event" && o.event === "agent");
const evtPromise = onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent",
);
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
const evt = await evtPromise;
expect(evt.payload.runId).toBe("run-1");
@@ -207,21 +254,32 @@ describe("gateway server", () => {
await server.close();
});
test("agent ack then final response", { timeout: 8000 }, async () => {
test("agent ack event then final response", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const ackP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted");
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
const ackP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.status === "accepted",
);
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1");
ws.send(
JSON.stringify({
type: "req",
@@ -241,45 +299,63 @@ describe("gateway server", () => {
await server.close();
});
test("agent dedupes by idempotencyKey after completion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
test(
"agent dedupes by idempotencyKey after completion",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const firstFinalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "same-agent" },
}),
);
const firstFinal = await firstFinalP;
const firstFinalP = onceMessage(
ws,
(o) =>
o.type === "res" &&
o.id === "ag1" &&
o.payload?.status !== "accepted",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "same-agent" },
}),
);
const firstFinal = await firstFinalP;
const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2");
ws.send(
JSON.stringify({
type: "req",
id: "ag2",
method: "agent",
params: { message: "hi again", idempotencyKey: "same-agent" },
}),
);
const second = await secondP;
expect(second.payload).toEqual(firstFinal.payload);
const secondP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "ag2",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag2",
method: "agent",
params: { message: "hi again", idempotencyKey: "same-agent" },
}),
);
const second = await secondP;
expect(second.payload).toEqual(firstFinal.payload);
ws.close();
await server.close();
});
ws.close();
await server.close();
},
);
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
@@ -288,55 +364,75 @@ describe("gateway server", () => {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const shutdownP = onceMessage(ws, (o) => o.type === "event" && o.event === "shutdown", 5000);
const shutdownP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "shutdown",
5000,
);
await server.close();
const evt = await shutdownP;
expect(evt.payload?.reason).toBeDefined();
});
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
c.send(
test(
"presence broadcast reaches multiple clients",
{ timeout: 8000 },
async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
c.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
await onceMessage(c, (o) => o.type === "hello-ok");
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
);
clients[0].send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
caps: [],
type: "req",
id: "broadcast",
method: "system-event",
params: { text: "fanout" },
}),
);
await onceMessage(c, (o) => o.type === "hello-ok");
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"));
clients[0].send(
JSON.stringify({
type: "req",
id: "broadcast",
method: "system-event",
params: { text: "fanout" },
}),
);
const events = await Promise.all(waits);
for (const evt of events) {
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
});
const events = await Promise.all(waits);
for (const evt of events) {
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
},
);
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
@@ -345,7 +441,12 @@ describe("gateway server", () => {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
@@ -387,7 +488,12 @@ describe("gateway server", () => {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
client: {
name: "test",
version: "1.0.0",
platform: "test",
mode: "test",
},
caps: [],
}),
);
@@ -397,7 +503,11 @@ describe("gateway server", () => {
const idem = "reconnect-agent";
const ws1 = await dial();
const final1P = onceMessage(ws1, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", 6000);
const final1P = onceMessage(
ws1,
(o) => o.type === "res" && o.id === "ag1",
6000,
);
ws1.send(
JSON.stringify({
type: "req",
@@ -410,7 +520,11 @@ describe("gateway server", () => {
ws1.close();
const ws2 = await dial();
const final2P = onceMessage(ws2, (o) => o.type === "res" && o.id === "ag2", 6000);
const final2P = onceMessage(
ws2,
(o) => o.type === "res" && o.id === "ag2",
6000,
);
ws2.send(
JSON.stringify({
type: "req",

View File

@@ -1,30 +1,33 @@
import os from "node:os";
import { randomUUID } from "node:crypto";
import { WebSocketServer, type WebSocket } from "ws";
import os from "node:os";
import { type WebSocket, WebSocketServer } from "ws";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot } from "../commands/health.js";
import { getStatusSummary } from "../commands/status.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
import {
listSystemPresence,
upsertPresence,
} from "../infra/system-presence.js";
import { logError } from "../logger.js";
import { defaultRuntime } from "../runtime.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import {
ErrorCodes,
type ErrorShape,
type Hello,
type RequestFrame,
type Snapshot,
errorShape,
formatValidationErrors,
type Hello,
PROTOCOL_VERSION,
type RequestFrame,
type Snapshot,
validateAgentParams,
validateHello,
validateRequestFrame,
validateSendParams,
} from "./protocol/index.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { onAgentEvent } from "../infra/agent-events.js";
type Client = {
socket: WebSocket;
@@ -71,21 +74,32 @@ const HANDSHAKE_TIMEOUT_MS = 3000;
const TICK_INTERVAL_MS = 30_000;
const DEDUPE_TTL_MS = 5 * 60_000;
const DEDUPE_MAX = 1000;
const SERVER_PROTO = 1;
type DedupeEntry = { ts: number; ok: boolean; payload?: unknown; error?: ErrorShape };
type DedupeEntry = {
ts: number;
ok: boolean;
payload?: unknown;
error?: ErrorShape;
};
const dedupe = new Map<string, DedupeEntry>();
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const wss = new WebSocketServer({ port, host: "127.0.0.1", maxPayload: MAX_PAYLOAD_BYTES });
const wss = new WebSocketServer({
port,
host: "127.0.0.1",
maxPayload: MAX_PAYLOAD_BYTES,
});
const clients = new Set<Client>();
const broadcast = (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean; stateVersion?: { presence?: number; health?: number } },
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => {
const frame = JSON.stringify({
type: "event",
@@ -206,11 +220,14 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const hello = parsed as Hello;
// protocol negotiation
const { minProtocol, maxProtocol } = hello;
if (maxProtocol < SERVER_PROTO || minProtocol > SERVER_PROTO) {
if (
maxProtocol < PROTOCOL_VERSION ||
minProtocol > PROTOCOL_VERSION
) {
send({
type: "hello-error",
reason: "protocol mismatch",
expectedProtocol: SERVER_PROTO,
expectedProtocol: PROTOCOL_VERSION,
});
socket.close(1002, "protocol mismatch");
close();
@@ -250,9 +267,12 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
snapshot.stateVersion.health = ++healthVersion;
const helloOk = {
type: "hello-ok",
protocol: SERVER_PROTO,
protocol: PROTOCOL_VERSION,
server: {
version: process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "dev",
version:
process.env.CLAWDIS_VERSION ??
process.env.npm_package_version ??
"dev",
commit: process.env.GIT_COMMIT,
host: os.hostname(),
connId,
@@ -284,11 +304,8 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
return;
}
const req = parsed as RequestFrame;
const respond = (
ok: boolean,
payload?: unknown,
error?: ErrorShape,
) => send({ type: "res", id: req.id, ok, payload, error });
const respond = (ok: boolean, payload?: unknown, error?: ErrorShape) =>
send({ type: "res", id: req.id, ok, payload, error });
switch (req.method) {
case "health": {
@@ -308,9 +325,15 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
break;
}
case "system-event": {
const text = String((req.params as { text?: unknown } | undefined)?.text ?? "").trim();
const text = String(
(req.params as { text?: unknown } | undefined)?.text ?? "",
).trim();
if (!text) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "text required"),
);
break;
}
enqueueSystemEvent(text);
@@ -320,7 +343,10 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: { presence: presenceVersion, health: healthVersion },
stateVersion: {
presence: presenceVersion,
health: healthVersion,
},
},
);
respond(true, { ok: true }, undefined);
@@ -407,9 +433,14 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
}
const message = params.message.trim();
const runId = params.sessionId || randomUUID();
const ackPayload = { runId, status: "accepted" as const };
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: ackPayload });
respond(true, ackPayload, undefined); // ack quickly
// Acknowledge via event to avoid double res frames
const ackEvent = {
type: "event",
event: "agent",
payload: { runId, status: "accepted" as const },
seq: ++seq,
};
socket.send(JSON.stringify(ackEvent));
try {
await agentCommand(
{
@@ -423,19 +454,43 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
defaultRuntime,
deps,
);
const payload = { runId, status: "ok" as const, summary: "completed" };
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload });
const payload = {
runId,
status: "ok" as const,
summary: "completed",
};
dedupe.set(`agent:${idem}`, {
ts: Date.now(),
ok: true,
payload,
});
respond(true, payload, undefined);
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
const payload = { runId, status: "error" as const, summary: String(err) };
dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: false, payload, error });
const payload = {
runId,
status: "error" as const,
summary: String(err),
};
dedupe.set(`agent:${idem}`, {
ts: Date.now(),
ok: false,
payload,
error,
});
respond(false, payload, error);
}
break;
}
default: {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unknown method: ${req.method}`,
),
);
break;
}
}
@@ -455,7 +510,10 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
return {
close: async () => {
broadcast("shutdown", { reason: "gateway stopping", restartExpectedMs: null });
broadcast("shutdown", {
reason: "gateway stopping",
restartExpectedMs: null,
});
clearInterval(tickInterval);
clearInterval(dedupeCleanup);
if (agentUnsub) {

View File

@@ -108,10 +108,7 @@ export function updateSystemPresence(text: string) {
entries.set(key, parsed);
}
export function upsertPresence(
key: string,
presence: Partial<SystemPresence>,
) {
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
ensureSelfPresence();
const existing = entries.get(key) ?? ({} as SystemPresence);
const merged: SystemPresence = {

View File

@@ -1,10 +1,10 @@
import type { AddressInfo } from "node:net";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
__forceWebChatSnapshotForTests,
startWebChatServer,
stopWebChatServer,
__forceWebChatSnapshotForTests,
__broadcastGatewayEventForTests,
} from "./server.js";
async function getFreePort(): Promise<number> {
@@ -12,80 +12,83 @@ async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as any).port as number;
const address = server.address() as AddressInfo;
const port = address.port as number;
server.close((err: Error | null) => (err ? reject(err) : resolve(port)));
});
});
}
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 8000) {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
const closeHandler = (code: number, reason: Buffer) => {
clearTimeout(timer);
ws.off("message", handler);
reject(new Error(`closed ${code}: ${reason.toString()}`));
};
const handler = (data: WebSocket.RawData) => {
const obj = JSON.parse(String(data));
if (filter(obj)) {
clearTimeout(timer);
ws.off("message", handler);
ws.off("close", closeHandler);
resolve(obj as T);
}
};
ws.on("message", handler);
ws.once("close", closeHandler);
});
}
type SnapshotMessage = {
type?: string;
snapshot?: { stateVersion?: { presence?: number } };
};
type SessionMessage = { type?: string };
describe("webchat server", () => {
test("hydrates snapshot to new sockets (offline mock)", { timeout: 8000 }, async () => {
const wPort = await getFreePort();
await startWebChatServer(wPort, undefined, { disableGateway: true });
const ws = new WebSocket(`ws://127.0.0.1:${wPort}/webchat/socket?session=test`);
const messages: any[] = [];
ws.on("message", (data) => {
try {
messages.push(JSON.parse(String(data)));
} catch {
/* ignore */
}
});
try {
await new Promise<void>((resolve) => ws.once("open", resolve));
__forceWebChatSnapshotForTests({
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 0,
test(
"hydrates snapshot to new sockets (offline mock)",
{ timeout: 8000 },
async () => {
const wPort = await getFreePort();
await startWebChatServer(wPort, undefined, { disableGateway: true });
const ws = new WebSocket(
`ws://127.0.0.1:${wPort}/webchat/socket?session=test`,
);
const messages: unknown[] = [];
ws.on("message", (data) => {
try {
messages.push(JSON.parse(String(data)));
} catch {
/* ignore */
}
});
const waitFor = async (pred: (m: any) => boolean, label: string) => {
const start = Date.now();
while (Date.now() - start < 3000) {
const found = messages.find((m) => {
try {
return pred(m);
} catch {
return false;
}
});
if (found) return found;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timeout waiting for ${label}`);
};
try {
await new Promise<void>((resolve) => ws.once("open", resolve));
await waitFor((m) => m?.type === "session", "session");
const snap = await waitFor((m) => m?.type === "gateway-snapshot", "snapshot");
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
} finally {
ws.close();
await stopWebChatServer();
}
});
__forceWebChatSnapshotForTests({
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 0,
});
const waitFor = async <T>(
pred: (m: unknown) => m is T,
label: string,
): Promise<T> => {
const start = Date.now();
while (Date.now() - start < 3000) {
const found = messages.find((m): m is T => {
try {
return pred(m);
} catch {
return false;
}
});
if (found) return found;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timeout waiting for ${label}`);
};
const isSessionMessage = (m: unknown): m is SessionMessage =>
typeof m === "object" &&
m !== null &&
(m as SessionMessage).type === "session";
const isSnapshotMessage = (m: unknown): m is SnapshotMessage =>
typeof m === "object" &&
m !== null &&
(m as SnapshotMessage).type === "gateway-snapshot";
await waitFor(isSessionMessage, "session");
const snap = await waitFor(isSnapshotMessage, "snapshot");
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
} finally {
ws.close();
await stopWebChatServer();
}
},
);
});

View File

@@ -1,19 +1,18 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type WebSocket, WebSocketServer } from "ws";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { logDebug, logError } from "../logger.js";
import { GatewayClient } from "../gateway/client.js";
import { randomUUID } from "node:crypto";
import { logDebug, logError } from "../logger.js";
const WEBCHAT_DEFAULT_PORT = 18788;
@@ -338,10 +337,20 @@ export async function startWebChatServer(
gatewayReady = true;
latestSnapshot = hello.snapshot as Record<string, unknown>;
latestPolicy = hello.policy as Record<string, unknown>;
broadcastAll({ type: "gateway-snapshot", snapshot: hello.snapshot, policy: hello.policy });
broadcastAll({
type: "gateway-snapshot",
snapshot: hello.snapshot,
policy: hello.policy,
});
},
onEvent: (evt) => {
broadcastAll({ type: "gateway-event", event: evt.event, payload: evt.payload, seq: evt.seq, stateVersion: evt.stateVersion });
broadcastAll({
type: "gateway-event",
event: evt.event,
payload: evt.payload,
seq: evt.seq,
stateVersion: evt.stateVersion,
});
},
onClose: () => {
gatewayReady = false;
@@ -517,10 +526,17 @@ export function __forceWebChatSnapshotForTests(
latestSnapshot = snapshot;
latestPolicy = policy ?? null;
gatewayReady = true;
broadcastAll({ type: "gateway-snapshot", snapshot: latestSnapshot, policy: latestPolicy });
broadcastAll({
type: "gateway-snapshot",
snapshot: latestSnapshot,
policy: latestPolicy,
});
}
export function __broadcastGatewayEventForTests(event: string, payload: unknown) {
export function __broadcastGatewayEventForTests(
event: string,
payload: unknown,
) {
broadcastAll({ type: "gateway-event", event, payload });
}