Compare commits
2 Commits
fix/node-i
...
fix/ios-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02c47043d | ||
|
|
6f42c623b9 |
@@ -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.
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
||||
### 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
|
||||
@@ -132,6 +132,13 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
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.speechRecognizer = SFSpeechRecognizer()
|
||||
guard let recognizer = self.speechRecognizer else {
|
||||
@@ -146,6 +153,11 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
let input = self.audioEngine.inputNode
|
||||
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)
|
||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||
|
||||
@@ -87,7 +87,15 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
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 {
|
||||
@@ -103,7 +111,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
await self.configure()
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
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 {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
@@ -280,28 +275,18 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
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 {
|
||||
var merged = params
|
||||
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)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -488,7 +487,6 @@ actor GatewayEndpointStore {
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
|
||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
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 controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
|
||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
|
||||
@@ -103,7 +103,6 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
@@ -148,10 +147,6 @@ final class TailscaleService {
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -219,8 +214,4 @@ final class TailscaleService {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
Self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,35 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
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 {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
@@ -52,45 +23,6 @@ public actor GatewayNodeSession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
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 canvasHostUrl: String?
|
||||
|
||||
@@ -235,11 +167,7 @@ public actor GatewayNodeSession {
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
let response = await onInvoke(req)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
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 { theme } from "../terminal/theme.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js";
|
||||
|
||||
function parseLimit(value: unknown): number | null {
|
||||
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 {
|
||||
const name = entry.name?.trim();
|
||||
const handle = entry.handle?.trim();
|
||||
const handleLabel = handle ? (handle.startsWith("@") ? handle : `@${handle}`) : null;
|
||||
const label = [name, handleLabel].filter(Boolean).join(" ");
|
||||
if (!label) return entry.id;
|
||||
return `${label} ${theme.muted(`(${entry.id})`)}`;
|
||||
function formatEntry(entry: { id: string; name?: string; handle?: string }) {
|
||||
const name = entry.name?.trim() ?? "";
|
||||
const handle = entry.handle?.trim() ?? "";
|
||||
const label = name || handle;
|
||||
return label ? `${entry.id} - ${label}` : entry.id;
|
||||
}
|
||||
|
||||
export function registerDirectoryCli(program: Command) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("dns cli", () => {
|
||||
it("prints setup info (no apply)", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = buildProgram();
|
||||
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";
|
||||
|
||||
export type HelpExample = readonly [command: string, description: string];
|
||||
export type HelpExample = [command: string, description: string];
|
||||
|
||||
export function formatHelpExample(command: string, description: string): string {
|
||||
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}`)}`;
|
||||
}
|
||||
|
||||
export function formatHelpExamples(examples: readonly HelpExample[], inline = false): string {
|
||||
export function formatHelpExamples(examples: HelpExample[], inline = false): string {
|
||||
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
||||
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
||||
}
|
||||
|
||||
export function formatHelpExampleGroup(
|
||||
label: string,
|
||||
examples: readonly HelpExample[],
|
||||
inline = false,
|
||||
) {
|
||||
export function formatHelpExampleGroup(label: string, examples: HelpExample[], inline = false) {
|
||||
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
||||
}
|
||||
|
||||
@@ -46,12 +46,16 @@ function formatNodeVersions(node: {
|
||||
|
||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "bigint") {
|
||||
defaultRuntime.error(`${label}: invalid duration`);
|
||||
let value = "";
|
||||
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);
|
||||
return undefined;
|
||||
}
|
||||
const value = String(raw).trim();
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return parseDurationMs(value);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
const listChannelPairingRequests = vi.fn();
|
||||
const approveChannelPairingCode = 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();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
||||
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("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();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
||||
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("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();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
|
||||
@@ -13,7 +13,7 @@ type CommandOptions = Record<string, unknown>;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const SANDBOX_EXAMPLES = {
|
||||
const SANDBOX_EXAMPLES: Record<string, [string, string][]> = {
|
||||
main: [
|
||||
["clawdbot sandbox list", "List all sandbox 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 --json", "JSON output."],
|
||||
],
|
||||
} as const;
|
||||
};
|
||||
|
||||
function createRunner(
|
||||
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
|
||||
// enough to avoid flaky RPC timeouts, but still fail fast when a response
|
||||
// never arrives.
|
||||
timeoutMs = 10_000,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||
|
||||
Reference in New Issue
Block a user