feat(connectors): surface active session integrations (#1091) (#1100)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 13:41:13 +03:00
1 parent 29bc367aec
commit 52e0104cd2
23 files changed
+6579 -6

No files matched your search

@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.config.ConnectionProfile
@@ -500,6 +501,11 @@ object AuthManager {
_baseUrlFlow.value = ""
}
@VisibleForTesting
fun setBaseUrlForTest(baseUrl: String) {
_baseUrlFlow.value = baseUrl
}
fun getToken(): String? {
if (tokenInitialized) return cachedToken
synchronized(this) {
@@ -0,0 +1,233 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.util.ConnectorUrlValidator
/**
* High-level connection status for a connector item.
* Deliberate precedence: if [connected] is true, status resolves to [CONNECTED]
* regardless of expired/revoked/stale statuses reported by backend.
*/
enum class ConnectorStatus {
CONNECTED,
INITIATED,
EXPIRED,
REVOKED,
FAILED,
DISCONNECTED,
UNKNOWN,
;
val isConnected: Boolean get() = this == CONNECTED
companion object {
fun from(
connected: Boolean,
rawStatus: String?,
): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus)
fun from(
connected: Boolean?,
rawStatus: String?,
): ConnectorStatus {
if (connected == true) return CONNECTED
if (connected == false) {
return when (rawStatus?.lowercase()?.trim()) {
"expired" -> EXPIRED
"revoked" -> REVOKED
"initiated" -> INITIATED
"failed", "error" -> FAILED
"disconnected" -> DISCONNECTED
null, "", "active", "connected", "authorized" -> DISCONNECTED
else -> UNKNOWN
}
}
// Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED
return when (rawStatus?.lowercase()?.trim()) {
"authorized", "active", "connected" -> CONNECTED
"expired" -> EXPIRED
"revoked" -> REVOKED
"initiated" -> INITIATED
"failed", "error" -> FAILED
"disconnected" -> DISCONNECTED
null, "" -> DISCONNECTED
else -> UNKNOWN
}
}
}
}
/**
* Status resulting from a connect/reconnect initiation.
*/
enum class ConnectorConnectStatus {
ACTIVE,
INITIATED,
FAILED,
UNKNOWN,
;
val isActive: Boolean get() = this == ACTIVE
companion object {
fun from(rawStatus: String?): ConnectorConnectStatus =
when (rawStatus?.lowercase()?.trim()) {
"active", "connected" -> ACTIVE
"initiated" -> INITIATED
"failed", "error" -> FAILED
null, "" -> UNKNOWN
else -> UNKNOWN
}
}
}
/**
* Individual connector item returned by `connectors.list`.
*/
data class ConnectorItem(
val connector: String,
val name: String = connector,
val connected: Boolean = false,
val enabled: Boolean = true,
val connectionStatus: String? = null,
val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus),
val connectUrl: String? = null,
) {
val isConnected: Boolean get() = connected || status.isConnected
/** Safe validated HTTPS URL, or null if invalid or absent. */
val safeConnectUrl: String?
get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null
override fun toString(): String {
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
return "ConnectorItem(" +
"connector=$connector, " +
"name=$name, " +
"connected=$connected, " +
"enabled=$enabled, " +
"status=$status, " +
"connectUrl=$redactedUrl" +
")"
}
}
/**
* Result of a connect/reconnect action for an individual connector.
* Instructions from arbitrary backends are intentionally not exposed for UI display.
*/
data class ConnectorConnectItem(
val connector: String,
val status: ConnectorConnectStatus,
val connectUrl: String? = null,
) {
val isConnectUrlValid: Boolean
get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl)
val safeConnectUrl: String?
get() = if (isConnectUrlValid) connectUrl else null
override fun toString(): String {
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
return "ConnectorConnectItem(" +
"connector=$connector, " +
"status=$status, " +
"connectUrl=$redactedUrl, " +
"isConnectUrlValid=$isConnectUrlValid" +
")"
}
}
/**
* Summary counts returned by `connectors.connect`.
*/
data class ConnectorConnectSummary(
val total: Int = 0,
val active: Int = 0,
val initiated: Int = 0,
val failed: Int = 0,
)
/**
* Typed safe connector errors mapped from RPC codes and reasons.
* Raw exceptions or auth tokens are never exposed.
*/
sealed interface ConnectorError {
val message: String
data class InvalidParams(
override val message: String = "Invalid connector parameters.",
) : ConnectorError
data class NotOwner(
override val message: String = "Session is not owned by this transport.",
) : ConnectorError
data class Unavailable(
override val message: String = "Connectors are not available in this session.",
) : ConnectorError
data class UnsupportedRuntime(
override val message: String = "Connectors must be managed on the session's compute host.",
) : ConnectorError
data class RequestFailed(
override val message: String = "Connector request failed. Please try again.",
) : ConnectorError
data class InvalidResponse(
override val message: String = "Connector service returned an invalid response.",
) : ConnectorError
data class UnsupportedBackend(
override val message: String = "Connector service is not supported by this backend.",
) : ConnectorError
data class MalformedEnvelope(
override val message: String = "Malformed response envelope received from gateway.",
) : ConnectorError
data class NetworkError(
override val message: String = "Failed to communicate with gateway.",
) : ConnectorError
data class Other(
val code: Int = 0,
override val message: String = "Connector operation failed.",
) : ConnectorError
}
/**
* Result envelope for `connectors.list`.
*/
sealed interface ConnectorListResult {
data class Success(
val available: Boolean,
val connectors: List<ConnectorItem>,
) : ConnectorListResult
data class Error(
val error: ConnectorError,
) : ConnectorListResult
fun getOrNull(): List<ConnectorItem>? = (this as? Success)?.connectors
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
/**
* Result envelope for `connectors.connect`.
*/
sealed interface ConnectorConnectResult {
data class Success(
val results: List<ConnectorConnectItem>,
val summary: ConnectorConnectSummary,
) : ConnectorConnectResult
data class Error(
val error: ConnectorError,
) : ConnectorConnectResult
fun getOrNull(): List<ConnectorConnectItem>? = (this as? Success)?.results
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
@@ -0,0 +1,389 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectItem
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorConnectSummary
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
/**
* Dedicated parser for connector payloads with strict envelope validation,
* resilient type coercion, legacy alias normalization, and safe error mapping.
*/
object ConnectorParser {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Parse raw result from `connectors.list` RPC.
* Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing.
*/
fun parseListResult(raw: Any?): ConnectorListResult {
val root =
toJsonElement(raw) as? JsonObject
?: return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."),
)
// Required envelope check: "connectors" must exist and be an array.
val connectorsArray =
root["connectors"] as? JsonArray
?: return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."),
)
// Optional 'available' flag (defaults to true if omitted, unless explicitly false)
val available = parseBoolean(root["available"], default = true)
val items = mutableListOf<ConnectorItem>()
val seenSlugs = mutableSetOf<String>()
for (element in connectorsArray) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectorItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
items.add(item)
}
}
// Entirely invalid nonempty connectors array must fail rather than looking silently valid empty.
if (connectorsArray.isNotEmpty() && items.isEmpty()) {
return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."),
)
}
return ConnectorListResult.Success(
available = available,
connectors = items,
)
}
/**
* Parse raw result from `connectors.connect` RPC.
* Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid.
*/
fun parseConnectResult(raw: Any?): ConnectorConnectResult {
val rawElement = toJsonElement(raw)
// Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ]
if (rawElement is JsonArray) {
val results = mutableListOf<ConnectorConnectItem>()
val seenSlugs = mutableSetOf<String>()
for (element in rawElement) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
results.add(item)
}
}
if (results.isNotEmpty()) {
val summary =
ConnectorConnectSummary(
total = results.size,
active = results.count { it.status == ConnectorConnectStatus.ACTIVE },
initiated = results.count { it.status == ConnectorConnectStatus.INITIATED },
failed = results.count { it.status == ConnectorConnectStatus.FAILED },
)
return ConnectorConnectResult.Success(
results = results,
summary = summary,
)
}
return ConnectorConnectResult.Error(
ConnectorError.InvalidResponse("Connect response contains no valid results."),
)
}
val root =
rawElement as? JsonObject
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect response must be a JSON object."),
)
// Required envelope check: "results" must exist and be an array.
val resultsArray =
root["results"] as? JsonArray
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."),
)
val results = mutableListOf<ConnectorConnectItem>()
val seenSlugs = mutableSetOf<String>()
for (element in resultsArray) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
results.add(item)
}
}
// Connect must return at least nonempty valid results
if (results.isEmpty()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidResponse("Connect response contains no valid results."),
)
}
// Current summary object is required in standard envelope
val summaryObj =
root["summary"] as? JsonObject
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."),
)
val summary = parseConnectSummary(summaryObj)
return ConnectorConnectResult.Success(
results = results,
summary = summary,
)
}
private fun parseConnectorItem(obj: JsonObject): ConnectorItem? {
// Strict string identifier with backend slug validation; legacy aliases supported
val slug =
parseIdentifier(obj["connector"])
?: parseIdentifier(obj["slug"])
?: parseIdentifier(obj["id"])
?: return null // Skip items without valid connector identifier
// Display name: primary "name", legacy aliases "displayName", "title", fallback to slug
val name =
parseString(obj["name"])
?: parseString(obj["displayName"])
?: parseString(obj["title"])
?: slug
val rawConnected = parseBooleanOrNull(obj["connected"])
val enabled = parseBoolean(obj["enabled"], default = true)
// Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status"
val rawStatus =
parseString(obj["connectionStatus"])
?: parseString(obj["status"])
?: parseString(obj["connection_status"])
// Compute typed status respecting connected precedence:
// When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED.
// Explicit connected=false is authoritative and not overridden by status active.
val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus)
val effectiveConnected = rawConnected ?: status.isConnected
// Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl"
val connectUrl =
parseString(obj["connect_url"])
?: parseString(obj["connectUrl"])
?: parseString(obj["auth_url"])
?: parseString(obj["authUrl"])
return ConnectorItem(
connector = slug,
name = name,
connected = effectiveConnected,
enabled = enabled,
connectionStatus = rawStatus,
status = status,
connectUrl = connectUrl,
)
}
private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? {
val slug =
parseIdentifier(obj["connector"])
?: parseIdentifier(obj["slug"])
?: parseIdentifier(obj["id"])
?: return null
val rawStatus =
parseString(obj["status"])
?: parseString(obj["connectionStatus"])
?: parseString(obj["connection_status"])
val status = ConnectorConnectStatus.from(rawStatus)
val connectUrl =
parseString(obj["connect_url"])
?: parseString(obj["connectUrl"])
?: parseString(obj["auth_url"])
?: parseString(obj["authUrl"])
return ConnectorConnectItem(
connector = slug,
status = status,
connectUrl = connectUrl,
)
}
private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary {
if (obj == null) return ConnectorConnectSummary()
return ConnectorConnectSummary(
total = parseInt(obj["total"], default = 0),
active = parseInt(obj["active"], default = 0),
initiated = parseInt(obj["initiated"], default = 0),
failed = parseInt(obj["failed"], default = 0),
)
}
/**
* Map RPC error codes and reasons into typed [ConnectorError]s.
* Raw server strings and sensitive parameters are never forwarded or exposed in error messages.
*/
fun mapRpcError(
code: Int,
message: String? = null,
data: JsonElement? = null,
): ConnectorError {
val reason =
when (data) {
is JsonObject -> (data["reason"] as? JsonPrimitive)?.content
is JsonPrimitive -> data.content
else -> null
}?.uppercase()?.trim()
return when {
code == 4000 || reason == "INVALID_PARAMS" -> {
ConnectorError.InvalidParams()
}
code == 4001 || reason == "NOT_OWNER" -> {
ConnectorError.NotOwner()
}
code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> {
ConnectorError.Unavailable()
}
code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> {
ConnectorError.UnsupportedRuntime()
}
code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> {
ConnectorError.InvalidResponse()
}
code == 5034 -> {
ConnectorError.RequestFailed()
}
code == -32601 -> {
ConnectorError.UnsupportedBackend()
}
else -> {
ConnectorError.Other(code = code)
}
}
}
internal fun parseStrictString(element: JsonElement?): String? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive && element.isString) {
return element.content
}
return null
}
internal fun parseIdentifier(element: JsonElement?): String? {
val str = parseStrictString(element)?.trim() ?: return null
return if (ConnectorRepository.isValidSlug(str)) str else null
}
internal fun parseString(element: JsonElement?): String? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive) {
if (!element.isString && element.content.isBlank()) return null
return element.content
}
return null
}
internal fun parseBooleanOrNull(element: JsonElement?): Boolean? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive) {
element.booleanOrNull?.let { return it }
val content = element.content.trim()
if (content.equals("true", ignoreCase = true) || content == "1") return true
if (content.equals("false", ignoreCase = true) || content == "0") return false
}
return null
}
internal fun parseBoolean(
element: JsonElement?,
default: Boolean,
): Boolean = parseBooleanOrNull(element) ?: default
internal fun parseInt(
element: JsonElement?,
default: Int,
): Int {
if (element == null || element is JsonNull) return default
if (element is JsonPrimitive) {
element.intOrNull?.let { return it }
element.content.toIntOrNull()?.let { return it }
}
return default
}
fun toJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
@Suppress("UNCHECKED_CAST")
(value as Map<String, Any?>).mapValues { (_, v) -> toJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { toJsonElement(it) })
}
is String -> {
val trimmed = value.trim()
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
try {
json.parseToJsonElement(value)
} catch (_: Exception) {
JsonPrimitive(value)
}
} else {
JsonPrimitive(value)
}
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
@@ -0,0 +1,126 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import kotlinx.coroutines.CancellationException
/**
* Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091).
* Public injectable interface enabling isolated testing in ViewModels and controllers.
*/
interface ConnectorRepository {
/**
* Request connector catalog and authorization status for [sessionId].
*/
suspend fun listConnectors(sessionId: String): ConnectorListResult
/**
* Initiate authorization flow for [connectors] slugs in [sessionId].
* If [reconnect] is true, restarts authorization flow even if already connected.
*/
suspend fun connect(
sessionId: String,
connectors: List<String>,
reconnect: Boolean = false,
): ConnectorConnectResult
companion object : ConnectorRepository by HermesConnectorRepository() {
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$")
fun isValidSlug(slug: String?): Boolean {
if (slug.isNullOrBlank()) return false
return SLUG_REGEX.matches(slug)
}
}
}
/**
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
*/
class HermesConnectorRepository(
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
} finally {
if (!deferred.isCompleted) {
deferred.cancel()
}
}
},
) : ConnectorRepository {
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
if (sessionId.isBlank()) {
return ConnectorListResult.Error(
ConnectorError.InvalidParams("session_id required"),
)
}
return try {
val params = mapOf("session_id" to sessionId)
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
ConnectorParser.parseListResult(result)
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
ConnectorListResult.Error(
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
)
} catch (_: Exception) {
ConnectorListResult.Error(
ConnectorError.NetworkError("Failed to communicate with gateway."),
)
}
}
override suspend fun connect(
sessionId: String,
connectors: List<String>,
reconnect: Boolean,
): ConnectorConnectResult {
if (sessionId.isBlank()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("session_id required"),
)
}
if (connectors.isEmpty()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
)
}
for (slug in connectors) {
if (!ConnectorRepository.isValidSlug(slug)) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
)
}
}
return try {
val params =
mapOf(
"session_id" to sessionId,
"connectors" to connectors,
"reconnect" to reconnect,
)
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
ConnectorParser.parseConnectResult(result)
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
ConnectorConnectResult.Error(
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
)
} catch (_: Exception) {
ConnectorConnectResult.Error(
ConnectorError.NetworkError("Failed to communicate with gateway."),
)
}
}
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -30,6 +31,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -702,6 +705,8 @@ object HermesWsClient {
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
class HermesRpcException(
message: String,
val code: Int = 0,
val data: JsonElement? = null,
) : Exception(message)
/**
@@ -741,6 +746,15 @@ object HermesWsClient {
send(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred)
}
deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) {
pendingCalls.remove(id)?.let { call ->
call.timeoutJob?.cancel()
removeQueuedMessage(id)
disconnectIfIdleInBackground()
}
}
}
// Arm the per-request timeout (fires if the server never answers).
pendingCalls[id]?.timeoutJob =
wsScope.launch {
@@ -760,7 +774,13 @@ object HermesWsClient {
removeQueuedMessage(id)
call.timeoutJob?.cancel()
if (error != null) {
call.deferred.completeExceptionally(HermesRpcException(error.message))
call.deferred.completeExceptionally(
HermesRpcException(
message = error.message,
code = error.code,
data = error.data,
),
)
} else {
call.deferred.complete(result)
}
@@ -808,7 +828,9 @@ object HermesWsClient {
params = decoratedParams.mapValues { it.value.toJsonElement() },
)
val json = OkHttpProvider.json.encodeToString(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
if (BuildConfig.DEBUG) {
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
}
var reconnect = false
synchronized(outboundLock) {
if (method == WsMethods.PROMPT_SUBMIT) {
@@ -1191,7 +1213,7 @@ object HermesWsClient {
markQueuedMessageSent(msg)
continue
}
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
if (!webSocket.send(msg)) {
recoverRejectedSocket(webSocket)
break
@@ -1209,7 +1231,7 @@ object HermesWsClient {
text: String,
) {
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
lastPongTimestamp = monotonicTimeMs()
// Resolve any in-flight `request()` awaiting this RPC result/error
// (issue #526) before fanning the parsed event out to collectors.
@@ -1269,7 +1291,7 @@ object HermesWsClient {
parsed
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
WsEvent.Unknown(text)
}
when (event) {
@@ -1381,4 +1403,60 @@ object HermesWsClient {
}
}
}
@VisibleForTesting
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
_connectionStatus.value = status
}
@VisibleForTesting
internal fun formatSafeOutgoingFrameLog(
id: String,
method: String,
paramsKeys: Collection<String>?,
byteLength: Int,
queued: Boolean = false,
): String {
val prefix = if (queued) "→ (queued)" else "→"
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
}
@VisibleForTesting
internal fun formatSafeQueuedFrameLog(msg: String): String =
try {
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
} catch (_: Throwable) {
"→ (queued) frame bytes=${msg.length}"
}
@VisibleForTesting
internal fun formatSafeIncomingFrameLog(text: String): String =
try {
val element = OkHttpProvider.json.parseToJsonElement(text)
if (element is JsonObject) {
val id = (element["id"] as? JsonPrimitive)?.content
val method = (element["method"] as? JsonPrimitive)?.content
val hasResult = element.containsKey("result")
val resultKeys =
(element["result"] as? JsonObject)?.keys?.joinToString(
",",
prefix = "[",
postfix = "]",
)
val errorObj = element["error"] as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
val sb = StringBuilder("← ")
if (id != null) sb.append("id=").append(id).append(" ")
if (method != null) sb.append("method=").append(method).append(" ")
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
sb.append("bytes=").append(text.length).toString()
} else {
"← non-object frame bytes=${text.length}"
}
} catch (_: Throwable) {
"← frame bytes=${text.length}"
}
}
@@ -91,6 +91,14 @@ object WsMethods {
/** Rolling tail snapshot of a subagent's live execution transcript. */
const val SUBAGENT_TAIL = "subagent.tail"
// ── Connectors (issue #1091) ───────────────────────────────────────────
/** List available third-party connectors and authorization state for a session. */
const val CONNECTORS_LIST = "connectors.list"
/** Initiate or restart authorization flow for one or more connectors. */
const val CONNECTORS_CONNECT = "connectors.connect"
// ── Billing / subscription (issue #628) ─────────────────────────────
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`