refactor(#1375): type plugins.manage and projects.list RPC params (#1391)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 23:14:13 +03:00
1 parent 9a09974081
commit 445da9fe14
11 files changed
+175 -73

No files matched your search

@@ -1,8 +1,10 @@
package com.m57.hermescontrol.data.ws package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CancellationException import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import kotlinx.serialization.json.Json import com.m57.hermescontrol.data.ws.contract.PluginsManageParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
@@ -11,23 +13,14 @@ import kotlinx.serialization.json.decodeFromJsonElement
* Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC.
*/ */
class PluginManageRepository( class PluginManageRepository(
private val rpcRequest: suspend (String, Map<String, Any?>) -> Any? = { method, params -> private val caller: TypedRpcCaller = HermesRpcCaller,
val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" })
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
}
},
) { ) {
suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> { suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> {
val params = mutableMapOf<String, Any?>("action" to "list") val element =
if (!profile.isNullOrBlank()) { caller.call(
params["profile"] = profile RpcMethods.PLUGINS_MANAGE,
} PluginsManageParams(action = "list", profile = profile.orNullIfBlank()),
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) )
val element = result.toJsonElement()
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement) return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
} }
@@ -38,15 +31,15 @@ class PluginManageRepository(
profile: String? = null, profile: String? = null,
): PluginsManageSettingsResult { ): PluginsManageSettingsResult {
val params = val params =
mutableMapOf<String, Any?>( PluginsManageParams(
"action" to "settings", action = "settings",
"key" to key, key = key,
"values" to JsonObject(values), values = JsonObject(values),
profile = profile.orNullIfBlank(),
) )
if (!profile.isNullOrBlank()) { val result = caller.call(RpcMethods.PLUGINS_MANAGE, params)
params["profile"] = profile return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result)
}
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result.toJsonElement())
} }
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
} }
@@ -1,6 +1,9 @@
package com.m57.hermescontrol.data.ws package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.CancellationException import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.PluginsManageParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
@@ -14,18 +17,10 @@ data class PluginRemovalResult(
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */ /** Removes a user-installed plugin through the gateway's canonical plugin manager. */
class PluginRemovalRepository( class PluginRemovalRepository(
private val rpcRequest: suspend (String, Map<String, Any>) -> Any? = { method, params -> private val caller: TypedRpcCaller = HermesRpcCaller,
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
}
},
) { ) {
suspend fun remove(name: String): PluginRemovalResult { suspend fun remove(name: String): PluginRemovalResult {
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name)) val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name))
return Json.decodeFromJsonElement<PluginRemovalResult>(result.toJsonElement()) return Json.decodeFromJsonElement<PluginRemovalResult>(result)
} }
} }
@@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ProjectInfo import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.ProjectsListResponse import com.m57.hermescontrol.data.model.ProjectsListResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.ProjectsListParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -17,14 +21,12 @@ class HermesProjectsSource(
// Only ask a live socket: an offline request would be queued and force a reconnect on // Only ask a live socket: an offline request would be queued and force a reconnect on
// every refresh. The view model refetches when the connection comes back. // every refresh. The view model refetches when the connection comes back.
private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED },
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params -> private val caller: TypedRpcCaller = HermesRpcCaller,
HermesWsClient.request(method, params).await()
},
) : ProjectsSource { ) : ProjectsSource {
override suspend fun fetchProjects(): List<ProjectInfo>? { override suspend fun fetchProjects(): List<ProjectInfo>? {
if (!isConnected()) return null if (!isConnected()) return null
return try { return try {
val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap())) val element = caller.call(RpcMethods.PROJECTS_LIST, ProjectsListParams)
if (element is JsonObject && element.containsKey("projects")) { if (element is JsonObject && element.containsKey("projects")) {
OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects
} else { } else {
@@ -0,0 +1,25 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* `plugins.manage` params. [action] has no default so it is always encoded; every other field is
* omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it.
*/
@Serializable
data class PluginsManageParams(
@SerialName("action") val action: String,
@SerialName("profile") val profile: String? = null,
@SerialName("key") val key: String? = null,
@SerialName("name") val name: String? = null,
@SerialName("enable") val enable: Boolean? = null,
@SerialName("identifier") val identifier: String? = null,
@SerialName("repo") val repo: String? = null,
@SerialName("catalog_name") val catalogName: String? = null,
@SerialName("force") val force: Boolean? = null,
@SerialName("ref") val ref: String? = null,
@SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null,
@SerialName("values") val values: JsonObject? = null,
)
@@ -0,0 +1,7 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.Serializable
/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */
@Serializable
data object ProjectsListParams
@@ -363,6 +363,22 @@ object RpcMethods {
JsonElement.serializer(), JsonElement.serializer(),
) )
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PLUGINS_MANAGE: RpcMethod<PluginsManageParams, JsonElement> =
RpcMethod(
WsMethods.PLUGINS_MANAGE,
PluginsManageParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROJECTS_LIST: RpcMethod<ProjectsListParams, JsonElement> =
RpcMethod(
WsMethods.PROJECTS_LIST,
ProjectsListParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */ /** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = val all: List<RpcMethod<*, *>> =
listOf( listOf(
@@ -410,5 +426,7 @@ object RpcMethods {
SUBAGENT_TAIL, SUBAGENT_TAIL,
PROCESS_LIST, PROCESS_LIST,
PROCESS_KILL, PROCESS_KILL,
PLUGINS_MANAGE,
PROJECTS_LIST,
) )
} }
@@ -16,6 +16,9 @@ import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
class PluginManageRepositoryTest { class PluginManageRepositoryTest {
private fun pluginRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
PluginManageRepository(fakeCaller(handler))
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
@Test @Test
fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() =
@@ -44,7 +47,7 @@ class PluginManageRepositoryTest {
) )
val repository = val repository =
PluginManageRepository { method, params -> pluginRepo { method, params ->
capturedMethod = method capturedMethod = method
capturedParams = params capturedParams = params
mockPayload mockPayload
@@ -80,7 +83,7 @@ class PluginManageRepositoryTest {
) )
val repository = val repository =
PluginManageRepository { method, params -> pluginRepo { method, params ->
capturedMethod = method capturedMethod = method
capturedParams = params capturedParams = params
mockPayload mockPayload
@@ -98,19 +101,19 @@ class PluginManageRepositoryTest {
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
runTest { runTest {
val emptyObjRepo = val emptyObjRepo =
PluginManageRepository { _, _ -> pluginRepo { _, _ ->
OkHttpProvider.json.parseToJsonElement("{}") OkHttpProvider.json.parseToJsonElement("{}")
} }
assertTrue(emptyObjRepo.listPlugins().isEmpty()) assertTrue(emptyObjRepo.listPlugins().isEmpty())
val nonObjRepo = val nonObjRepo =
PluginManageRepository { _, _ -> pluginRepo { _, _ ->
OkHttpProvider.json.parseToJsonElement("[]") OkHttpProvider.json.parseToJsonElement("[]")
} }
assertTrue(nonObjRepo.listPlugins().isEmpty()) assertTrue(nonObjRepo.listPlugins().isEmpty())
val nullRepo = val nullRepo =
PluginManageRepository { _, _ -> pluginRepo { _, _ ->
null null
} }
assertTrue(nullRepo.listPlugins().isEmpty()) assertTrue(nullRepo.listPlugins().isEmpty())
@@ -139,7 +142,7 @@ class PluginManageRepositoryTest {
) )
val repository = val repository =
PluginManageRepository { method, params -> pluginRepo { method, params ->
capturedMethod = method capturedMethod = method
capturedParams = params capturedParams = params
mockResponse mockResponse
@@ -156,7 +159,7 @@ class PluginManageRepositoryTest {
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
assertEquals("settings", capturedParams["action"]) assertEquals("settings", capturedParams["action"])
assertEquals("camofox", capturedParams["key"]) assertEquals("camofox", capturedParams["key"])
assertEquals(JsonObject(valuesToSave), capturedParams["values"]) assertEquals(mapOf("port" to 9222, "headless" to true), capturedParams["values"])
assertFalse(capturedParams.containsKey("profile")) assertFalse(capturedParams.containsKey("profile"))
assertTrue(result.ok) assertTrue(result.ok)
@@ -186,7 +189,7 @@ class PluginManageRepositoryTest {
) )
val repository = val repository =
PluginManageRepository { method, params -> pluginRepo { method, params ->
capturedMethod = method capturedMethod = method
capturedParams = params capturedParams = params
mockResponse mockResponse
@@ -204,7 +207,7 @@ class PluginManageRepositoryTest {
mapOf( mapOf(
"action" to "settings", "action" to "settings",
"key" to "camofox", "key" to "camofox",
"values" to JsonObject(valuesToSave), "values" to mapOf("port" to 8080),
"profile" to "staging", "profile" to "staging",
), ),
capturedParams, capturedParams,
@@ -222,7 +225,7 @@ class PluginManageRepositoryTest {
fun `saveSettings throws SerializationException when response is malformed`() = fun `saveSettings throws SerializationException when response is malformed`() =
runTest { runTest {
val repository = val repository =
PluginManageRepository { _, _ -> pluginRepo { _, _ ->
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
} }
@@ -9,13 +9,16 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class PluginRemovalRepositoryTest { class PluginRemovalRepositoryTest {
private fun removalRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
PluginRemovalRepository(fakeCaller(handler))
@Test @Test
fun `remove sends scoped gateway action and decodes success`() = fun `remove sends scoped gateway action and decodes success`() =
runTest { runTest {
var method = "" var method = ""
var params: Map<String, Any> = emptyMap() var params: Map<String, Any> = emptyMap()
val repository = val repository =
PluginRemovalRepository { requestedMethod, requestedParams -> removalRepo { requestedMethod, requestedParams ->
method = requestedMethod method = requestedMethod
params = requestedParams params = requestedParams
Json.parseToJsonElement("""{"ok":true,"name":"demo"}""") Json.parseToJsonElement("""{"ok":true,"name":"demo"}""")
@@ -33,11 +36,11 @@ class PluginRemovalRepositoryTest {
@Test @Test
fun `refusal is a typed failure and missing ok cannot masquerade as success`() = fun `refusal is a typed failure and missing ok cannot masquerade as success`() =
runTest { runTest {
val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } val refused = removalRepo { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") }
assertFalse(refused.remove("builtin").ok) assertFalse(refused.remove("builtin").ok)
assertEquals("bundled plugin", refused.remove("builtin").error) assertEquals("bundled plugin", refused.remove("builtin").error)
val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") } val malformed = removalRepo { _, _ -> mapOf("name" to "demo") }
try { try {
malformed.remove("demo") malformed.remove("demo")
throw AssertionError("Missing ok must fail decoding") throw AssertionError("Missing ok must fail decoding")
@@ -8,12 +8,17 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ProjectsSourceTest { class ProjectsSourceTest {
private fun projectsSource(
connected: Boolean = true,
handler: suspend (String, Map<String, Any>) -> Any?,
) = HermesProjectsSource(isConnected = { connected }, caller = fakeCaller(handler))
@Test @Test
fun `decodes the untyped rpc map`() = fun `decodes the untyped rpc map`() =
runTest { runTest {
var calledMethod: String? = null var calledMethod: String? = null
val source = val source =
HermesProjectsSource(isConnected = { true }) { method, _ -> projectsSource { method, _ ->
calledMethod = method calledMethod = method
mapOf( mapOf(
"projects" to "projects" to
@@ -49,13 +54,10 @@ class ProjectsSourceTest {
runTest { runTest {
var calls = 0 var calls = 0
val source = val source =
HermesProjectsSource( projectsSource(connected = false) { _, _ ->
rpcRequest = { _, _ -> calls++
calls++ mapOf("projects" to emptyList<Any>())
mapOf("projects" to emptyList<Any>()) }
},
isConnected = { false },
)
assertNull(source.fetchProjects()) assertNull(source.fetchProjects())
assertEquals("a disconnected fetch must not queue an RPC", 0, calls) assertEquals("a disconnected fetch must not queue an RPC", 0, calls)
@@ -65,10 +67,10 @@ class ProjectsSourceTest {
fun `rpc failure or unexpected payload yields null`() = fun `rpc failure or unexpected payload yields null`() =
runTest { runTest {
assertNull( assertNull(
HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(), projectsSource { _, _ -> error("method not found") }.fetchProjects(),
) )
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects()) assertNull(projectsSource { _, _ -> "nope" }.fetchProjects())
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects()) assertNull(projectsSource { _, _ -> null }.fetchProjects())
} }
@Test @Test
@@ -76,9 +78,7 @@ class ProjectsSourceTest {
runTest { runTest {
val thrown = val thrown =
runCatching { runCatching {
HermesProjectsSource( projectsSource { _, _ -> throw CancellationException("gone") }.fetchProjects()
isConnected = { true },
) { _, _ -> throw CancellationException("gone") }.fetchProjects()
}.exceptionOrNull() }.exceptionOrNull()
assertTrue(thrown is CancellationException) assertTrue(thrown is CancellationException)
} }
@@ -318,10 +318,6 @@ class RawRpcParamsRatchetTest {
listOf( listOf(
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
"data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE",
"data/ws/ProjectsSource.kt::PROJECTS_LIST",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
@@ -0,0 +1,60 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PluginsProjectsTypesTest {
private fun encode(params: PluginsManageParams) =
OkHttpProvider.json.encodeToJsonElement(PluginsManageParams.serializer(), params)
@Test
fun listEncodesOnlyAction() {
assertEquals(buildJsonObject { put("action", "list") }, encode(PluginsManageParams(action = "list")))
}
@Test
fun removeEncodesActionAndName() {
assertEquals(
buildJsonObject {
put("action", "remove")
put("name", "demo")
},
encode(PluginsManageParams(action = "remove", name = "demo")),
)
}
@Test
fun settingsEncodesKeyValuesAndExplicitProfile() {
val values = buildJsonObject { put("port", JsonPrimitive(8080)) }
assertEquals(
buildJsonObject {
put("action", "settings")
put("profile", "staging")
put("key", "camofox")
put("values", values)
},
encode(PluginsManageParams(action = "settings", key = "camofox", values = values, profile = "staging")),
)
}
@Test
fun projectsListEncodesAsEmptyObject() {
assertEquals(
buildJsonObject { },
OkHttpProvider.json.encodeToJsonElement(ProjectsListParams.serializer(), ProjectsListParams),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
assertTrue(RpcMethods.PLUGINS_MANAGE in RpcMethods.all)
assertTrue(RpcMethods.PROJECTS_LIST in RpcMethods.all)
assertEquals("plugins.manage", RpcMethods.PLUGINS_MANAGE.name)
assertEquals("projects.list", RpcMethods.PROJECTS_LIST.name)
}
}