fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236)
This commit is contained in:
13 files changed
+475
-94
No files matched your search
@@ -24,6 +24,9 @@ data class PluginInfo(
|
||||
get() =
|
||||
runtimeStatus.equals("enabled", ignoreCase = true) ||
|
||||
runtimeStatus.equals("disabled", ignoreCase = true)
|
||||
|
||||
val removable: Boolean
|
||||
get() = canRemove && (source == "user" || source == "git")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -665,11 +665,6 @@ interface HermesApiService : KanbanApiService {
|
||||
@Body body: AgentPluginInstallBody,
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("api/dashboard/agent-plugins/{name}")
|
||||
suspend fun uninstallPlugin(
|
||||
@Path("name", encoded = true) name: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/dashboard/agent-plugins/{name}/update")
|
||||
suspend fun updatePlugin(
|
||||
@Path("name", encoded = true) name: String,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
@Serializable
|
||||
data class PluginRemovalResult(
|
||||
val ok: Boolean,
|
||||
val name: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */
|
||||
class PluginRemovalRepository(
|
||||
private val rpcRequest: 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
|
||||
}
|
||||
},
|
||||
) {
|
||||
suspend fun remove(name: String): PluginRemovalResult {
|
||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name))
|
||||
return Json.decodeFromJsonElement<PluginRemovalResult>(result.toJsonElement())
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,9 @@ object WsMethods {
|
||||
/** Initiate or restart authorization flow for one or more connectors. */
|
||||
const val CONNECTORS_CONNECT = "connectors.connect"
|
||||
|
||||
// ── Plugins ───────────────────────────────────────────────────────────
|
||||
const val PLUGINS_MANAGE = "plugins.manage"
|
||||
|
||||
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
||||
@@ -174,6 +177,7 @@ object WsMethods {
|
||||
CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped
|
||||
"verification.status", // line 282
|
||||
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
|
||||
PLUGINS_MANAGE, // methods_tools.py: @_scoped_rpc
|
||||
"pet.info", // line 1253
|
||||
"pet.info.meta", // line 1279
|
||||
"pet.cells", // line 1302
|
||||
|
||||
@@ -486,7 +486,7 @@ private fun InstallSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginCard(
|
||||
internal fun PluginCard(
|
||||
plugin: PluginInfo,
|
||||
state: PluginsUiState,
|
||||
viewModel: PluginsViewModel,
|
||||
@@ -542,12 +542,7 @@ private fun PluginCard(
|
||||
|
||||
// Status badges
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val statusColor =
|
||||
when {
|
||||
plugin.enabled -> statusColors.success
|
||||
plugin.installed -> statusColors.warning
|
||||
else -> statusColors.error
|
||||
}
|
||||
val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning
|
||||
Text(
|
||||
text = plugin.runtimeStatus ?: "inactive",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -591,64 +586,63 @@ private fun PluginCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (plugin.installed) {
|
||||
// Visibility toggle (only for plugins with dashboard manifest)
|
||||
if (plugin.hasDashboardManifest) {
|
||||
IconButton(
|
||||
onClick = { viewModel.togglePluginVisibility(plugin) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription =
|
||||
if (plugin.userHidden) {
|
||||
stringResource(R.string.plugins_show_in_sidebar)
|
||||
} else {
|
||||
stringResource(R.string.plugins_hide_from_sidebar)
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// Visibility toggle (only for active plugins with dashboard manifests)
|
||||
if (plugin.installed && plugin.hasDashboardManifest) {
|
||||
IconButton(
|
||||
onClick = { viewModel.togglePluginVisibility(plugin) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription =
|
||||
if (plugin.userHidden) {
|
||||
stringResource(R.string.plugins_show_in_sidebar)
|
||||
} else {
|
||||
stringResource(R.string.plugins_hide_from_sidebar)
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
// Existing update affordance for active Git installs.
|
||||
if (plugin.installed && plugin.canUpdateGit) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.updatePlugin(plugin.name) },
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_update))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
// Backend-provided removability, independent of runtime enabled state.
|
||||
if (plugin.removable) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.requestRemovePlugin(plugin.name) },
|
||||
enabled = !busy,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.plugins_action_uninstall))
|
||||
}
|
||||
|
||||
// Update button
|
||||
if (plugin.canUpdateGit) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.updatePlugin(plugin.name) },
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_update))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
// Remove button with confirmation
|
||||
if (plugin.canRemove) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.requestRemovePlugin(plugin.name) },
|
||||
enabled = !busy,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.plugins_action_uninstall))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!plugin.installed) {
|
||||
Button(
|
||||
onClick = { viewModel.activatePlugin(plugin) },
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_action_install))
|
||||
Text(stringResource(R.string.plugins_action_enable))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import com.m57.hermescontrol.data.model.ProviderOption
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -72,15 +74,18 @@ data class PluginsUiState(
|
||||
}
|
||||
}
|
||||
|
||||
class PluginsViewModel :
|
||||
ViewModel(),
|
||||
class PluginsViewModel(
|
||||
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
|
||||
private var scopeGeneration = 0
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
scopeGeneration++
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
@@ -333,26 +338,24 @@ class PluginsViewModel :
|
||||
}
|
||||
|
||||
fun activatePlugin(plugin: PluginInfo) {
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
safeLaunchAction(
|
||||
onStart = { setRowBusy(plugin.name) },
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } },
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
|
||||
loadPlugins(forceRefresh = true)
|
||||
},
|
||||
onError = { error ->
|
||||
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") }
|
||||
},
|
||||
onComplete = { clearRowBusy(plugin.name) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Show confirmation dialog for removing a plugin */
|
||||
fun requestRemovePlugin(name: String) {
|
||||
if (_uiState.value.plugins.none { it.name == name && it.removable }) return
|
||||
_uiState.update { it.copy(removeConfirmPlugin = name) }
|
||||
}
|
||||
|
||||
@@ -363,24 +366,85 @@ class PluginsViewModel :
|
||||
|
||||
fun confirmRemovePlugin() {
|
||||
val name = _uiState.value.removeConfirmPlugin ?: return
|
||||
if (_uiState.value.plugins.none { it.name == name && it.removable } || _uiState.value.rowBusy != null) {
|
||||
cancelRemovePlugin()
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(removeConfirmPlugin = null) }
|
||||
val generation = scopeGeneration
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
setRowBusy(name)
|
||||
viewModelScope.launch {
|
||||
setRowBusy(name)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
|
||||
try {
|
||||
val result = pluginRemoval.remove(name)
|
||||
if (generation != scopeGeneration ||
|
||||
(requestScope != null && AuthManager.currentDataScope() != requestScope)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins(forceRefresh = true)
|
||||
if (!result.ok) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to uninstall plugin: ${result.error ?: "Gateway refused removal"}",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val refreshed = safeApiCall { ApiClient.hermesApi.getPlugins() }
|
||||
if (generation != scopeGeneration ||
|
||||
(requestScope != null && AuthManager.currentDataScope() != requestScope)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
when (refreshed) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = refreshed.data
|
||||
requestScope?.let { pluginsCache.put(it.scopedKey("default"), data) }
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
plugins = data.plugins,
|
||||
orphanPlugins =
|
||||
data.orphanDashboardPlugins.map { orphan ->
|
||||
PluginInfo(
|
||||
name = orphan.name ?: "unknown",
|
||||
description =
|
||||
orphan.description ?: orphan.label,
|
||||
)
|
||||
},
|
||||
memoryProvider = data.providers?.memoryProvider ?: "",
|
||||
memoryOptions = data.providers?.memoryOptions.orEmpty(),
|
||||
contextEngine = data.providers?.contextEngine ?: "compressor",
|
||||
contextOptions = data.providers?.contextOptions.orEmpty(),
|
||||
toastMessage =
|
||||
if (data.plugins.none { it.name == name }) {
|
||||
"Plugin uninstalled successfully"
|
||||
} else {
|
||||
"Removal returned success, but $name still appears in the plugin list"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
|
||||
clearRowBusy(name)
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Removal sent; could not verify plugin list: ${refreshed.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (generation == scopeGeneration) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to uninstall plugin: ${e.message ?: "Gateway unavailable"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (generation == scopeGeneration) clearRowBusy(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user