This commit is contained in:
19 files changed
+1557
-14
No files matched your search
@@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot(
|
||||
val settled: Boolean,
|
||||
val settledBy: String?,
|
||||
val targets: List<ConnectionOperationTarget>,
|
||||
val accountOwned: Boolean = false,
|
||||
)
|
||||
|
||||
data class ConnectionOperationTarget(
|
||||
|
||||
@@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult {
|
||||
|
||||
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
|
||||
}
|
||||
|
||||
/** Account-level connector records exposed outside the chat session lifecycle. */
|
||||
data class ConnectorAccount(
|
||||
val connectionId: String,
|
||||
val connector: String,
|
||||
val status: String,
|
||||
val statusReason: String?,
|
||||
val label: String,
|
||||
val alias: String?,
|
||||
val active: Boolean,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
data class ConnectorCatalogEntry(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val category: String,
|
||||
)
|
||||
|
||||
data class ConnectorTool(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val facet: String,
|
||||
val deprecated: Boolean,
|
||||
val hints: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ConnectorPolicy(
|
||||
val revision: String,
|
||||
val mode: String,
|
||||
val connectors: List<String>,
|
||||
val disabledConnectors: List<String>,
|
||||
val tools: Map<String, List<String>>,
|
||||
val enabledTags: List<String> = emptyList(),
|
||||
val disabledTags: List<String> = emptyList(),
|
||||
val member: ConnectorPolicy? = null,
|
||||
val inherited: List<ConnectorPolicy> = emptyList(),
|
||||
) {
|
||||
fun toolEnabled(
|
||||
slug: String,
|
||||
tool: ConnectorTool,
|
||||
): Boolean =
|
||||
connectorEnabled(slug) && tool.slug !in tools[slug].orEmpty() &&
|
||||
disabledTags.none { it in tool.hints } && (enabledTags.isEmpty() || enabledTags.any { it in tool.hints })
|
||||
|
||||
fun connectorEnabled(slug: String): Boolean =
|
||||
when (mode) {
|
||||
"unrestricted" -> true
|
||||
"deny-all" -> false
|
||||
"allow" -> slug in connectors
|
||||
"deny" -> slug !in disabledConnectors
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AccountConnectorResult<out T> {
|
||||
data class Success<T>(
|
||||
val value: T,
|
||||
) : AccountConnectorResult<T>
|
||||
|
||||
data class Failure(
|
||||
val error: ConnectorError,
|
||||
) : AccountConnectorResult<Nothing>
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorAccount
|
||||
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
interface AccountConnectorRepository {
|
||||
suspend fun catalog(): AccountConnectorResult<List<ConnectorCatalogEntry>>
|
||||
|
||||
suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
|
||||
suspend fun connect(
|
||||
slugs: List<String>,
|
||||
reconnect: Boolean = false,
|
||||
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
|
||||
|
||||
suspend fun accounts(): AccountConnectorResult<List<ConnectorAccount>>
|
||||
|
||||
suspend fun remove(connectionId: String): AccountConnectorResult<Unit>
|
||||
|
||||
suspend fun tools(
|
||||
slug: String,
|
||||
refresh: Boolean = false,
|
||||
): AccountConnectorResult<List<ConnectorTool>>
|
||||
|
||||
suspend fun policy(): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun setConnectorEnabled(
|
||||
slug: String,
|
||||
enabled: Boolean,
|
||||
expectedRevision: String,
|
||||
): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun setDisabledTools(
|
||||
slug: String,
|
||||
disabled: List<String>,
|
||||
expectedRevision: String,
|
||||
): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun operationRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any?
|
||||
|
||||
suspend fun operationStatus(
|
||||
opId: String,
|
||||
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
|
||||
|
||||
companion object : AccountConnectorRepository by HermesAccountConnectorRepository()
|
||||
}
|
||||
|
||||
class HermesAccountConnectorRepository(
|
||||
private val rpc: suspend (String, 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()
|
||||
}
|
||||
},
|
||||
) : AccountConnectorRepository {
|
||||
private suspend fun request(
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
) = rpc(method, params)
|
||||
|
||||
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
|
||||
try {
|
||||
AccountConnectorResult.Success(block())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
AccountConnectorResult.Failure(ConnectorParser.mapRpcError(e.code, e.message, e.data))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
AccountConnectorResult.Failure(ConnectorError.InvalidResponse())
|
||||
} catch (_: IllegalStateException) {
|
||||
AccountConnectorResult.Failure(ConnectorError.InvalidResponse())
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway."))
|
||||
}
|
||||
|
||||
private fun accountParams(params: Map<String, Any> = emptyMap()): Map<String, Any> =
|
||||
buildMap {
|
||||
putAll(params)
|
||||
AuthManager.activeProfileId.value?.let { put("profile", it) }
|
||||
}
|
||||
|
||||
private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject
|
||||
|
||||
private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
|
||||
private fun JsonObject.bool(k: String) = this[k]?.jsonPrimitive?.contentOrNull == "true"
|
||||
|
||||
private fun JsonObject.array(k: String): JsonArray = this[k]?.jsonArray ?: JsonArray(emptyList())
|
||||
|
||||
private fun JsonObject.stringList(k: String) = array(k).mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
|
||||
override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult =
|
||||
try {
|
||||
ConnectorParser.parseListResult(
|
||||
rpc(
|
||||
WsMethods.CONNECTORS_LIST,
|
||||
accountParams(
|
||||
mapOf("owner" to mapOf("type" to "account")),
|
||||
),
|
||||
),
|
||||
)
|
||||
} catch (
|
||||
e: CancellationException,
|
||||
) {
|
||||
throw e
|
||||
} catch (
|
||||
e: HermesWsClient.HermesRpcException,
|
||||
) {
|
||||
com.m57.hermescontrol.data.model.ConnectorListResult.Error(
|
||||
ConnectorParser.mapRpcError(e.code, e.message, e.data),
|
||||
)
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
.Error(ConnectorError.NetworkError())
|
||||
}
|
||||
|
||||
override suspend fun connect(
|
||||
slugs: List<String>,
|
||||
reconnect: Boolean,
|
||||
) = guarded {
|
||||
require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug))
|
||||
parseOperation(
|
||||
request(
|
||||
WsMethods.CONNECTORS_CONNECT,
|
||||
accountParams(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"connectors" to slugs,
|
||||
"reconnect" to reconnect,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun operationRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account"))))
|
||||
|
||||
override suspend fun operationStatus(opId: String) =
|
||||
guarded {
|
||||
parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId)))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseOperation(raw: Any?): com.m57.hermescontrol.data.model.ConnectionOperationSnapshot =
|
||||
checkNotNull(
|
||||
ConnectionOperationParser.parse(
|
||||
(ConnectorParser.toJsonElement(raw).toAny() as Map<String, Any?>) +
|
||||
("owner" to mapOf("type" to "account")),
|
||||
),
|
||||
)
|
||||
|
||||
override suspend fun catalog() =
|
||||
guarded {
|
||||
obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map {
|
||||
val row = it.jsonObject
|
||||
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun accounts() =
|
||||
guarded {
|
||||
obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e ->
|
||||
val r = e.jsonObject
|
||||
ConnectorAccount(
|
||||
r.str("connection_id"),
|
||||
r.str("connector"),
|
||||
r.str("status"),
|
||||
r["status_reason"]?.jsonPrimitive?.contentOrNull,
|
||||
r.str("label"),
|
||||
r["alias"]?.jsonPrimitive?.contentOrNull,
|
||||
r.bool("active"),
|
||||
r.str("created_at"),
|
||||
r.str("updated_at"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(connectionId: String) =
|
||||
guarded {
|
||||
require(connectionId.isNotBlank())
|
||||
val response =
|
||||
obj(
|
||||
request(
|
||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||
accountParams(
|
||||
mapOf("connection_id" to connectionId),
|
||||
),
|
||||
),
|
||||
)
|
||||
check(response.str("status") == "removed" && response.str("connection_id") == connectionId)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun tools(
|
||||
slug: String,
|
||||
refresh: Boolean,
|
||||
) = guarded {
|
||||
require(ConnectorRepository.isValidSlug(slug))
|
||||
val params =
|
||||
buildMap<String, Any> {
|
||||
put("slug", slug)
|
||||
if (refresh) put("refresh", true)
|
||||
}
|
||||
obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e ->
|
||||
val r = e.jsonObject
|
||||
ConnectorTool(
|
||||
r.str("slug"),
|
||||
r.str("name"),
|
||||
r.str("description"),
|
||||
r.str("facet"),
|
||||
r.bool("deprecated"),
|
||||
r.stringList("hints"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun policy() =
|
||||
guarded {
|
||||
val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams()))
|
||||
val layers = root.array("layers").map { it.jsonObject }
|
||||
val member = layers.firstOrNull { it.str("kind") == "member" }
|
||||
parsePolicy(root["effective"]!!.jsonObject).copy(
|
||||
member = member?.let { parsePolicy(it["body"]!!.jsonObject, it.str("revision")) },
|
||||
inherited =
|
||||
layers.filter { it.str("kind") != "member" }.map {
|
||||
parsePolicy(it["body"]!!.jsonObject, it.str("revision"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun setConnectorEnabled(
|
||||
slug: String,
|
||||
enabled: Boolean,
|
||||
expectedRevision: String,
|
||||
) = setPolicy(
|
||||
expectedRevision,
|
||||
mapOf(
|
||||
"type" to "connector",
|
||||
"connector" to slug,
|
||||
"enabled" to enabled,
|
||||
),
|
||||
)
|
||||
|
||||
override suspend fun setDisabledTools(
|
||||
slug: String,
|
||||
disabled: List<String>,
|
||||
expectedRevision: String,
|
||||
) = setPolicy(
|
||||
expectedRevision,
|
||||
mapOf(
|
||||
"type" to "tools",
|
||||
"connector" to slug,
|
||||
"disabled_tools" to disabled,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun setPolicy(
|
||||
expectedRevision: String,
|
||||
change: Map<String, Any>,
|
||||
) = guarded {
|
||||
val scope = AuthManager.currentDataScope()
|
||||
require(ConnectorRepository.isValidSlug(change["connector"] as? String))
|
||||
require(expectedRevision.isNotBlank())
|
||||
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
||||
val result =
|
||||
obj(
|
||||
request(
|
||||
WsMethods.CONNECTORS_POLICY_SET,
|
||||
accountParams(
|
||||
mapOf(
|
||||
"change" to change,
|
||||
"expected_revision" to expectedRevision,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
||||
when (val refreshed = policy()) {
|
||||
is AccountConnectorResult.Success -> refreshed.value
|
||||
is AccountConnectorResult.Failure -> parsePolicy(result["effective"]!!.jsonObject)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePolicy(
|
||||
r: JsonObject,
|
||||
revision: String = r.str("revision"),
|
||||
) = ConnectorPolicy(
|
||||
revision,
|
||||
r.str("mode"),
|
||||
r.stringList("connectors"),
|
||||
r.stringList("disabled_connectors"),
|
||||
(r["tools"] as? JsonObject)?.mapValues { (_, v) ->
|
||||
(v as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
}
|
||||
?: emptyMap(),
|
||||
enabledTags =
|
||||
(r["tags"] as? JsonObject)
|
||||
?.let {
|
||||
(it["enable"] as? JsonArray)?.mapNotNull { tag ->
|
||||
tag.jsonPrimitive.contentOrNull
|
||||
}
|
||||
}.orEmpty(),
|
||||
disabledTags =
|
||||
(r["tags"] as? JsonObject)
|
||||
?.let {
|
||||
(it["disable"] as? JsonArray)?.mapNotNull { tag ->
|
||||
tag.jsonPrimitive.contentOrNull
|
||||
}
|
||||
}.orEmpty(),
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ object ConnectionOperationParser {
|
||||
settled = payload["settled"] as? Boolean ?: false,
|
||||
settledBy = payload["settled_by"] as? String,
|
||||
targets = targets,
|
||||
accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,19 @@ object WsMethods {
|
||||
|
||||
const val CONNECTION_RESPOND = "connection.respond"
|
||||
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
|
||||
const val CONNECTORS_OPERATION_STATUS = "connectors.operation.status"
|
||||
|
||||
/** 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"
|
||||
const val CONNECTORS_CATALOG = "connectors.catalog"
|
||||
const val CONNECTORS_ACCOUNTS = "connectors.accounts"
|
||||
const val CONNECTORS_ACCOUNTS_REMOVE = "connectors.accounts.remove"
|
||||
const val CONNECTORS_TOOLS = "connectors.tools"
|
||||
const val CONNECTORS_POLICY_GET = "connectors.policy.get"
|
||||
const val CONNECTORS_POLICY_SET = "connectors.policy.set"
|
||||
|
||||
// ── Plugins ───────────────────────────────────────────────────────────
|
||||
const val PLUGINS_MANAGE = "plugins.manage"
|
||||
@@ -165,6 +172,12 @@ object WsMethods {
|
||||
SESSION_CREATE, // session.create
|
||||
SESSION_LIST, // session.list
|
||||
MODEL_OPTIONS, // model.options: @_profile_scoped
|
||||
CONNECTORS_CATALOG,
|
||||
CONNECTORS_ACCOUNTS,
|
||||
CONNECTORS_ACCOUNTS_REMOVE,
|
||||
CONNECTORS_TOOLS,
|
||||
CONNECTORS_POLICY_GET,
|
||||
CONNECTORS_POLICY_SET,
|
||||
SESSION_RESUME, // session.resume
|
||||
SESSION_DELETE, // session.delete
|
||||
SESSION_STATUS, // session.status
|
||||
|
||||
Reference in new issue
Block a user