Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02c47043d | ||
|
|
6f42c623b9 |
No files matched your search
+1
-1
@@ -25,6 +25,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||||
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- iOS: explain Talk mode is unavailable on the simulator to avoid Speech live-audio crashes. (#1358) — thanks @vignesh07.
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||||
@@ -32,7 +33,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||||
- Nodes: enforce node.invoke timeouts for node handlers. (#1357) — thanks @vignesh07.
|
|
||||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ final class TalkModeManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startRecognition() throws {
|
private func startRecognition() throws {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
// Apple Speech live-audio recognition is not supported on Simulator.
|
||||||
|
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator (Speech live audio requires a device).",
|
||||||
|
])
|
||||||
|
#endif
|
||||||
|
|
||||||
self.stopRecognition()
|
self.stopRecognition()
|
||||||
self.speechRecognizer = SFSpeechRecognizer()
|
self.speechRecognizer = SFSpeechRecognizer()
|
||||||
guard let recognizer = self.speechRecognizer else {
|
guard let recognizer = self.speechRecognizer else {
|
||||||
@@ -146,6 +153,11 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
let input = self.audioEngine.inputNode
|
let input = self.audioEngine.inputNode
|
||||||
let format = input.outputFormat(forBus: 0)
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||||
|
throw NSError(domain: "TalkMode", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Invalid audio input format",
|
||||||
|
])
|
||||||
|
}
|
||||||
input.removeTap(onBus: 0)
|
input.removeTap(onBus: 0)
|
||||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||||
|
|||||||
@@ -87,7 +87,15 @@ final class ControlChannel {
|
|||||||
|
|
||||||
func configure() async {
|
func configure() async {
|
||||||
self.logger.info("control channel configure mode=local")
|
self.logger.info("control channel configure mode=local")
|
||||||
await self.refreshEndpoint(reason: "configure")
|
self.state = .connecting
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.refresh()
|
||||||
|
self.state = .connected
|
||||||
|
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||||
|
} catch {
|
||||||
|
let message = self.friendlyGatewayMessage(error)
|
||||||
|
self.state = .degraded(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(mode: Mode = .local) async throws {
|
func configure(mode: Mode = .local) async throws {
|
||||||
@@ -103,7 +111,7 @@ final class ControlChannel {
|
|||||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
await self.refreshEndpoint(reason: "configure")
|
await self.configure()
|
||||||
} catch {
|
} catch {
|
||||||
self.state = .degraded(error.localizedDescription)
|
self.state = .degraded(error.localizedDescription)
|
||||||
throw error
|
throw error
|
||||||
@@ -111,19 +119,6 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshEndpoint(reason: String) async {
|
|
||||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
|
||||||
self.state = .connecting
|
|
||||||
do {
|
|
||||||
try await self.establishGatewayConnection()
|
|
||||||
self.state = .connected
|
|
||||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
|
||||||
} catch {
|
|
||||||
let message = self.friendlyGatewayMessage(error)
|
|
||||||
self.state = .degraded(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() async {
|
func disconnect() async {
|
||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
@@ -280,28 +275,18 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
do {
|
||||||
if case .connected = self.state {
|
try await GatewayConnection.shared.refresh()
|
||||||
self.logger.info("control channel recovery finished")
|
self.logger.info("control channel recovery finished")
|
||||||
} else if case let .degraded(message) = self.state {
|
} catch {
|
||||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
self.logger.error(
|
||||||
|
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recoveryTask = nil
|
self.recoveryTask = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
|
||||||
try await GatewayConnection.shared.refresh()
|
|
||||||
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
|
||||||
if ok == false {
|
|
||||||
throw NSError(
|
|
||||||
domain: "Gateway",
|
|
||||||
code: 0,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
var merged = params
|
var merged = params
|
||||||
merged["text"] = AnyHashable(text)
|
merged["text"] = AnyHashable(text)
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
final class GatewayConnectivityCoordinator {
|
|
||||||
static let shared = GatewayConnectivityCoordinator()
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
|
||||||
private var endpointTask: Task<Void, Never>?
|
|
||||||
private var lastResolvedURL: URL?
|
|
||||||
|
|
||||||
private(set) var endpointState: GatewayEndpointState?
|
|
||||||
private(set) var resolvedURL: URL?
|
|
||||||
private(set) var resolvedMode: AppState.ConnectionMode?
|
|
||||||
private(set) var resolvedHostLabel: String?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
self.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard self.endpointTask == nil else { return }
|
|
||||||
self.endpointTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let stream = await GatewayEndpointStore.shared.subscribe()
|
|
||||||
for await state in stream {
|
|
||||||
await MainActor.run { self.handleEndpointState(state) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var localEndpointHostLabel: String? {
|
|
||||||
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
|
||||||
return Self.hostLabel(for: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleEndpointState(_ state: GatewayEndpointState) {
|
|
||||||
self.endpointState = state
|
|
||||||
switch state {
|
|
||||||
case let .ready(mode, url, _, _):
|
|
||||||
self.resolvedMode = mode
|
|
||||||
self.resolvedURL = url
|
|
||||||
self.resolvedHostLabel = Self.hostLabel(for: url)
|
|
||||||
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
|
||||||
if urlChanged {
|
|
||||||
self.lastResolvedURL = url
|
|
||||||
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
|
||||||
}
|
|
||||||
case let .connecting(mode, _):
|
|
||||||
self.resolvedMode = mode
|
|
||||||
case let .unavailable(mode, _):
|
|
||||||
self.resolvedMode = mode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hostLabel(for url: URL) -> String {
|
|
||||||
let host = url.host ?? url.absoluteString
|
|
||||||
if let port = url.port { return "\(host):\(port)" }
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,6 @@ actor GatewayEndpointStore {
|
|||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
?? TailscaleService.fallbackTailnetIPv4()
|
|
||||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
bindMode: bind,
|
bindMode: bind,
|
||||||
customBindHost: customBindHost,
|
customBindHost: customBindHost,
|
||||||
@@ -488,7 +487,6 @@ actor GatewayEndpointStore {
|
|||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
?? TailscaleService.fallbackTailnetIPv4()
|
|
||||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
|||||||
let lower = error.lowercased()
|
let lower = error.lowercased()
|
||||||
if lower.contains("connection refused") {
|
if lower.contains("connection refused") {
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
"restart Clawdbot to bring it back."
|
||||||
}
|
}
|
||||||
if lower.contains("timeout") {
|
if lower.contains("timeout") {
|
||||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ struct ClawdbotApp: App {
|
|||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
case .local:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
host = "127.0.0.1:\(port)"
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
platform = nil
|
platform = nil
|
||||||
host = nil
|
host = nil
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkTailscaleStatus() async {
|
func checkTailscaleStatus() async {
|
||||||
let previousIP = self.tailscaleIP
|
|
||||||
self.isInstalled = self.checkAppInstallation()
|
self.isInstalled = self.checkAppInstallation()
|
||||||
if !self.isInstalled {
|
if !self.isInstalled {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
@@ -148,10 +147,6 @@ final class TailscaleService {
|
|||||||
self.statusError = nil
|
self.statusError = nil
|
||||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if previousIP != self.tailscaleIP {
|
|
||||||
await GatewayEndpointStore.shared.refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func openTailscaleApp() {
|
func openTailscaleApp() {
|
||||||
@@ -219,8 +214,4 @@ final class TailscaleService {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
|
||||||
Self.detectTailnetIPv4()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,35 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
|||||||
var idempotencyKey: String?
|
var idempotencyKey: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensures the timeout can win even if the invoke task never completes.
|
|
||||||
private actor InvokeTimeoutRace {
|
|
||||||
private var finished = false
|
|
||||||
private let continuation: CheckedContinuation<BridgeInvokeResponse, Never>
|
|
||||||
private var invokeTask: Task<Void, Never>?
|
|
||||||
private var timeoutTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
|
|
||||||
self.continuation = continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerTasks(invoke: Task<Void, Never>, timeout: Task<Void, Never>) {
|
|
||||||
self.invokeTask = invoke
|
|
||||||
self.timeoutTask = timeout
|
|
||||||
if finished {
|
|
||||||
invoke.cancel()
|
|
||||||
timeout.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish(_ response: BridgeInvokeResponse) {
|
|
||||||
guard !finished else { return }
|
|
||||||
finished = true
|
|
||||||
continuation.resume(returning: response)
|
|
||||||
invokeTask?.cancel()
|
|
||||||
timeoutTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public actor GatewayNodeSession {
|
public actor GatewayNodeSession {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
@@ -52,45 +23,6 @@ public actor GatewayNodeSession {
|
|||||||
private var onConnected: (@Sendable () async -> Void)?
|
private var onConnected: (@Sendable () async -> Void)?
|
||||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||||
|
|
||||||
static func invokeWithTimeout(
|
|
||||||
request: BridgeInvokeRequest,
|
|
||||||
timeoutMs: Int?,
|
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
|
||||||
) async -> BridgeInvokeResponse {
|
|
||||||
let timeout = max(0, timeoutMs ?? 0)
|
|
||||||
guard timeout > 0 else {
|
|
||||||
return await onInvoke(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
let cappedTimeout = min(timeout, Int(UInt64.max / 1_000_000))
|
|
||||||
let timeoutResponse = BridgeInvokeResponse(
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdbotNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "node invoke timed out")
|
|
||||||
)
|
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
let race = InvokeTimeoutRace(continuation: continuation)
|
|
||||||
let invokeTask = Task {
|
|
||||||
let response = await onInvoke(request)
|
|
||||||
await race.finish(response)
|
|
||||||
}
|
|
||||||
let timeoutTask = Task {
|
|
||||||
do {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(cappedTimeout) * 1_000_000)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await race.finish(timeoutResponse)
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await race.registerTasks(invoke: invokeTask, timeout: timeoutTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||||
private var canvasHostUrl: String?
|
private var canvasHostUrl: String?
|
||||||
|
|
||||||
@@ -235,11 +167,7 @@ public actor GatewayNodeSession {
|
|||||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||||
guard let onInvoke else { return }
|
guard let onInvoke else { return }
|
||||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||||
let response = await Self.invokeWithTimeout(
|
let response = await onInvoke(req)
|
||||||
request: req,
|
|
||||||
timeoutMs: request.timeoutMs,
|
|
||||||
onInvoke: onInvoke
|
|
||||||
)
|
|
||||||
await self.sendInvokeResult(request: request, response: response)
|
await self.sendInvokeResult(request: request, response: response)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import ClawdbotKit
|
|
||||||
import ClawdbotProtocol
|
|
||||||
|
|
||||||
struct GatewayNodeSessionTests {
|
|
||||||
@Test
|
|
||||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
|
||||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
|
||||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
|
||||||
request: request,
|
|
||||||
timeoutMs: 50,
|
|
||||||
onInvoke: { req in
|
|
||||||
#expect(req.id == "1")
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(response.ok == true)
|
|
||||||
#expect(response.error == nil)
|
|
||||||
#expect(response.payloadJSON == "{}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
|
||||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
|
||||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
|
||||||
request: request,
|
|
||||||
timeoutMs: 10,
|
|
||||||
onInvoke: { _ in
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
|
||||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(response.ok == false)
|
|
||||||
#expect(response.error?.code == .unavailable)
|
|
||||||
#expect(response.error?.message.contains("timed out") == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func invokeWithTimeoutReturnsWhenHandlerNeverCompletes() async {
|
|
||||||
let request = BridgeInvokeRequest(id: "stall", command: "x", paramsJSON: nil)
|
|
||||||
let response = try? await AsyncTimeout.withTimeoutMs(
|
|
||||||
timeoutMs: 200,
|
|
||||||
onTimeout: { NSError(domain: "GatewayNodeSessionTests", code: 1) },
|
|
||||||
operation: {
|
|
||||||
await GatewayNodeSession.invokeWithTimeout(
|
|
||||||
request: request,
|
|
||||||
timeoutMs: 10,
|
|
||||||
onInvoke: { _ in
|
|
||||||
await withCheckedContinuation { _ in }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(response != nil)
|
|
||||||
#expect(response?.ok == false)
|
|
||||||
#expect(response?.error?.code == .unavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
|
||||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
|
||||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
|
||||||
request: request,
|
|
||||||
timeoutMs: 0,
|
|
||||||
onInvoke: { req in
|
|
||||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(response.ok == true)
|
|
||||||
#expect(response.error == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js";
|
|
||||||
|
|
||||||
function parseLimit(value: unknown): number | null {
|
function parseLimit(value: unknown): number | null {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
@@ -31,13 +30,11 @@ function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEntry(entry: ChannelDirectoryEntry): string {
|
function formatEntry(entry: { id: string; name?: string; handle?: string }) {
|
||||||
const name = entry.name?.trim();
|
const name = entry.name?.trim() ?? "";
|
||||||
const handle = entry.handle?.trim();
|
const handle = entry.handle?.trim() ?? "";
|
||||||
const handleLabel = handle ? (handle.startsWith("@") ? handle : `@${handle}`) : null;
|
const label = name || handle;
|
||||||
const label = [name, handleLabel].filter(Boolean).join(" ");
|
return label ? `${entry.id} - ${label}` : entry.id;
|
||||||
if (!label) return entry.id;
|
|
||||||
return `${label} ${theme.muted(`(${entry.id})`)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerDirectoryCli(program: Command) {
|
export function registerDirectoryCli(program: Command) {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
|
||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
describe("dns cli", () => {
|
describe("dns cli", () => {
|
||||||
it("prints setup info (no apply)", async () => {
|
it("prints setup info (no apply)", async () => {
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["dns", "setup"], { from: "user" });
|
await program.parseAsync(["dns", "setup"], { from: "user" });
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("clawdbot.internal"));
|
const output = log.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||||
|
expect(output).toContain("clawdbot.internal");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
export type HelpExample = readonly [command: string, description: string];
|
export type HelpExample = [command: string, description: string];
|
||||||
|
|
||||||
export function formatHelpExample(command: string, description: string): string {
|
export function formatHelpExample(command: string, description: string): string {
|
||||||
return ` ${theme.command(command)}\n ${theme.muted(description)}`;
|
return ` ${theme.command(command)}\n ${theme.muted(description)}`;
|
||||||
@@ -11,15 +11,11 @@ export function formatHelpExampleLine(command: string, description: string): str
|
|||||||
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
|
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHelpExamples(examples: readonly HelpExample[], inline = false): string {
|
export function formatHelpExamples(examples: HelpExample[], inline = false): string {
|
||||||
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
||||||
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHelpExampleGroup(
|
export function formatHelpExampleGroup(label: string, examples: HelpExample[], inline = false) {
|
||||||
label: string,
|
|
||||||
examples: readonly HelpExample[],
|
|
||||||
inline = false,
|
|
||||||
) {
|
|
||||||
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
||||||
}
|
}
|
||||||
@@ -46,12 +46,16 @@ function formatNodeVersions(node: {
|
|||||||
|
|
||||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||||
if (raw === undefined || raw === null) return undefined;
|
if (raw === undefined || raw === null) return undefined;
|
||||||
if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "bigint") {
|
let value = "";
|
||||||
defaultRuntime.error(`${label}: invalid duration`);
|
if (typeof raw === "string") {
|
||||||
|
value = raw.trim();
|
||||||
|
} else if (typeof raw === "number") {
|
||||||
|
value = `${raw}`;
|
||||||
|
} else {
|
||||||
|
defaultRuntime.error(`${label}: expected a duration string`);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const value = String(raw).trim();
|
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
try {
|
try {
|
||||||
return parseDurationMs(value);
|
return parseDurationMs(value);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
|
||||||
const listChannelPairingRequests = vi.fn();
|
const listChannelPairingRequests = vi.fn();
|
||||||
const approveChannelPairingCode = vi.fn();
|
const approveChannelPairingCode = vi.fn();
|
||||||
const notifyPairingApproved = vi.fn();
|
const notifyPairingApproved = vi.fn();
|
||||||
@@ -64,14 +66,14 @@ describe("pairing cli", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name("test");
|
program.name("test");
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
const output = log.mock.calls.map(([value]) => String(value)).join("\n");
|
const output = log.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||||
expect(output).toContain("telegramUserId");
|
expect(output).toContain("telegramUserId");
|
||||||
expect(output).toContain("123");
|
expect(output).toContain("123");
|
||||||
});
|
});
|
||||||
@@ -126,14 +128,14 @@ describe("pairing cli", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name("test");
|
program.name("test");
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
const output = log.mock.calls.map(([value]) => String(value)).join("\n");
|
const output = log.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||||
expect(output).toContain("discordUserId");
|
expect(output).toContain("discordUserId");
|
||||||
expect(output).toContain("999");
|
expect(output).toContain("999");
|
||||||
});
|
});
|
||||||
@@ -150,7 +152,7 @@ describe("pairing cli", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name("test");
|
program.name("test");
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type CommandOptions = Record<string, unknown>;
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
const SANDBOX_EXAMPLES = {
|
const SANDBOX_EXAMPLES: Record<string, [string, string][]> = {
|
||||||
main: [
|
main: [
|
||||||
["clawdbot sandbox list", "List all sandbox containers."],
|
["clawdbot sandbox list", "List all sandbox containers."],
|
||||||
["clawdbot sandbox list --browser", "List only browser containers."],
|
["clawdbot sandbox list --browser", "List only browser containers."],
|
||||||
@@ -40,7 +40,7 @@ const SANDBOX_EXAMPLES = {
|
|||||||
["clawdbot sandbox explain --agent work", "Explain an agent sandbox."],
|
["clawdbot sandbox explain --agent work", "Explain an agent sandbox."],
|
||||||
["clawdbot sandbox explain --json", "JSON output."],
|
["clawdbot sandbox explain --json", "JSON output."],
|
||||||
],
|
],
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
function createRunner(
|
function createRunner(
|
||||||
commandFn: (opts: CommandOptions, runtime: typeof defaultRuntime) => Promise<void>,
|
commandFn: (opts: CommandOptions, runtime: typeof defaultRuntime) => Promise<void>,
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function onceMessage<T = unknown>(
|
|||||||
// Full-suite runs can saturate the event loop (581+ files). Keep this high
|
// Full-suite runs can saturate the event loop (581+ files). Keep this high
|
||||||
// enough to avoid flaky RPC timeouts, but still fail fast when a response
|
// enough to avoid flaky RPC timeouts, but still fail fast when a response
|
||||||
// never arrives.
|
// never arrives.
|
||||||
timeoutMs = 10_000,
|
timeoutMs = 15_000,
|
||||||
): Promise<T> {
|
): 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);
|
||||||
|
|||||||
Reference in new issue
Block a user