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

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] 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]
} }
} }

View File

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

View File

@@ -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)"
} }
} }
} }

View File

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

View File

@@ -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).",

View File

@@ -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()

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,
}; };

View File

@@ -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" });

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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)}`);

View File

@@ -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,
}; };

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 }); 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",

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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 = {

View File

@@ -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();
}
},
);
}); });

View File

@@ -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 });
} }