From a2b4e55a5c7086cb7d8292bb7e4dbc46539ca4f4 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:39:12 +1000 Subject: [PATCH 1/2] Fix broken flow capability identity --- .../features/flows/model/NodeModels.kt | 9 +++++-- .../features/flows/ui/FlowRunnerView.kt | 8 +++++-- .../features/flows/viewmodel/FlowViewModel.kt | 8 +++++-- .../features/flows/FlowBrokenTest.kt | 24 +++++++++++++++---- .../features/flows/FlowReadinessTest.kt | 6 +++-- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt index 02d119c8..89426dc9 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt @@ -490,10 +490,12 @@ data class Flow( return fallbackType } - fun isBroken(activeCapabilities: Set): Boolean { + fun isBroken(activeCapabilities: Set): Boolean { val hasBrokenNode = this.nodes.any { it is Node.CapabilityNode && it.isBroken } val hasMissingCapability = - this.nodes.filterIsInstance().any { it.capability.name !in activeCapabilities } + this.nodes.filterIsInstance().any { + CapabilityIdentity(it.pluginInfo.id, it.capability.name) !in activeCapabilities + } val hasNotReadyNode = this.nodes.any { !it.isReady(connections) } return hasBrokenNode || hasMissingCapability || hasNotReadyNode } @@ -521,6 +523,9 @@ data class Flow( } } +/** Stable, collision-free identity for a capability installed in the host. */ +data class CapabilityIdentity(val pluginId: String, val capabilityName: String) + @Serializable data class PortConstraints( val regex: String? = null, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt index 064ebb82..a3b3130c 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt @@ -45,6 +45,7 @@ import org.wip.plugintoolkit.api.format import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.flows.model.Node +import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity import org.wip.plugintoolkit.features.flows.viewmodel.FlowViewModel import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.job.model.JobType @@ -101,8 +102,11 @@ fun FlowRunnerView( val activeCapabilities = remember(state.flows) { org.wip.plugintoolkit.features.plugin.logic.PluginLoader.getPlugins() .mapNotNull { it.getManifest().getOrNull() } - .flatMap { it.capabilities } - .map { it.name } + .flatMap { manifest -> + manifest.capabilities.map { capability -> + CapabilityIdentity(manifest.plugin.id, capability.name) + } + } .toSet() } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt index 66f84fb7..8f35db4d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt @@ -27,6 +27,7 @@ import org.wip.plugintoolkit.features.flows.logic.FlowRepository import org.wip.plugintoolkit.features.flows.model.Connection import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.flows.model.Node +import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobStatus @@ -559,8 +560,11 @@ class FlowViewModel( fun executeFlow(flow: Flow, parameterValues: Map) { val activeCapabilities = org.wip.plugintoolkit.features.plugin.logic.PluginLoader.getPlugins() .mapNotNull { it.getManifest().getOrNull() } - .flatMap { it.capabilities } - .map { it.name } + .flatMap { manifest -> + manifest.capabilities.map { capability -> + CapabilityIdentity(manifest.plugin.id, capability.name) + } + } .toSet() if (flow.isBroken(activeCapabilities)) { diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowBrokenTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowBrokenTest.kt index 0cd3c685..bcf3740a 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowBrokenTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowBrokenTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.geometry.Offset import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.features.flows.model.Flow +import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity import org.wip.plugintoolkit.features.flows.model.Node import kotlin.test.Test import kotlin.test.assertFalse @@ -52,7 +53,10 @@ class FlowBrokenTest { ) ) - val activeCapabilities = setOf("Capability A", "Capability B") + val activeCapabilities = setOf( + CapabilityIdentity("test.plugin", "Capability A"), + CapabilityIdentity("test.plugin", "Capability B") + ) assertFalse(flow.isBroken(activeCapabilities), "Flow should not be broken") } @@ -67,7 +71,7 @@ class FlowBrokenTest { ) // Only Capability A is active, meaning Capability B is missing - val activeCapabilities = setOf("Capability A") + val activeCapabilities = setOf(CapabilityIdentity("test.plugin", "Capability A")) assertTrue(flow.isBroken(activeCapabilities), "Flow should be broken due to missing capability B") } @@ -80,7 +84,7 @@ class FlowBrokenTest { ) ) - val activeCapabilities = setOf("Capability A") + val activeCapabilities = setOf(CapabilityIdentity("test.plugin", "Capability A")) assertTrue( flow.isBroken(activeCapabilities), "Flow should be broken because the node is explicitly marked as broken" @@ -94,7 +98,19 @@ class FlowBrokenTest { nodes = emptyList() ) - val activeCapabilities = setOf("Capability A") + val activeCapabilities = setOf(CapabilityIdentity("test.plugin", "Capability A")) assertFalse(flow.isBroken(activeCapabilities), "Empty flow should not be broken") } + + @Test + fun `capabilities with the same name from another plugin do not make a flow executable`() { + val flow = Flow( + name = "Namespaced Flow", + nodes = listOf(createCapabilityNode(capabilityA)) + ) + + val activeCapabilities = setOf(CapabilityIdentity("another.plugin", "Capability A")) + + assertTrue(flow.isBroken(activeCapabilities), "Capability identity must include the plugin id") + } } diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowReadinessTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowReadinessTest.kt index f0a72791..1529deee 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowReadinessTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowReadinessTest.kt @@ -8,6 +8,7 @@ import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.api.PrimitiveType import org.wip.plugintoolkit.features.flows.model.Connection import org.wip.plugintoolkit.features.flows.model.Flow +import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity import org.wip.plugintoolkit.features.flows.model.InputPort import org.wip.plugintoolkit.features.flows.model.Node import kotlin.test.Test @@ -89,12 +90,13 @@ class FlowReadinessTest { val flow = Flow(name = "Test Flow", nodes = listOf(node)) - assertTrue(flow.isBroken(setOf("TestCap")), "Flow should be broken if a node is not ready") + val activeCapabilities = setOf(CapabilityIdentity(pluginInfo.id, "TestCap")) + assertTrue(flow.isBroken(activeCapabilities), "Flow should be broken if a node is not ready") val valueNode = node.copyWithUpdatedInput("reqPort", kotlinx.serialization.json.JsonPrimitive("value")) val readyFlow = Flow(name = "Test Flow", nodes = listOf(valueNode)) - assertFalse(readyFlow.isBroken(setOf("TestCap")), "Flow should not be broken if all nodes are ready") + assertFalse(readyFlow.isBroken(activeCapabilities), "Flow should not be broken if all nodes are ready") } @Test From 21b03241071b72bcd306f72c213c3837372a8432 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:35:03 +1000 Subject: [PATCH 2/2] fix: refresh broken flows when plugins change --- .../features/flows/model/NodeModels.kt | 8 ++++++++ .../features/flows/ui/FlowManagerView.kt | 13 ++++++++++--- .../features/flows/ui/FlowRunnerView.kt | 13 +++++-------- .../features/flows/viewmodel/FlowViewModel.kt | 9 ++------- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt index 89426dc9..3837497d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/model/NodeModels.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.json.put import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.PluginInfo +import org.wip.plugintoolkit.api.PluginManifest import org.wip.plugintoolkit.api.SemanticType import org.wip.plugintoolkit.api.parseSemanticTypes import org.wip.plugintoolkit.features.flows.logic.PathPatternResolver @@ -526,6 +527,13 @@ data class Flow( /** Stable, collision-free identity for a capability installed in the host. */ data class CapabilityIdentity(val pluginId: String, val capabilityName: String) +fun Iterable.capabilityIdentities(): Set = + flatMap { manifest -> + manifest.capabilities.map { capability -> + CapabilityIdentity(manifest.plugin.id, capability.name) + } + }.toSet() + @Serializable data class PortConstraints( val regex: String? = null, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowManagerView.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowManagerView.kt index f7c6af0f..eded17f7 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowManagerView.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowManagerView.kt @@ -61,6 +61,8 @@ import org.jetbrains.compose.resources.stringResource import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.core.utils.PlatformUtils import org.wip.plugintoolkit.features.flows.model.Node +import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity +import org.wip.plugintoolkit.features.flows.model.capabilityIdentities import org.wip.plugintoolkit.features.flows.viewmodel.ConflictResolutionAction import org.wip.plugintoolkit.features.flows.viewmodel.FlowEvent import org.wip.plugintoolkit.features.flows.viewmodel.FlowViewModel @@ -112,6 +114,8 @@ fun FlowManagerView( ) { val state by viewModel.state.collectAsState() val pluginManager = org.koin.compose.koinInject() + val loadedPlugins by pluginManager.loadedPlugins.collectAsState() + val installedPlugins by pluginManager.installedPlugins.collectAsState() var showCreateDialog by remember { mutableStateOf(false) } var newFlowName by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") } @@ -125,8 +129,10 @@ fun FlowManagerView( // // flows are reloaded automatically via flowRepository // } - val activeCapabilities = remember(state.flows) { - PluginLoader.getPlugins().flatMap { it.getManifest().getOrThrow().capabilities.map { cap -> cap.name } }.toSet() + val activeCapabilities = remember(loadedPlugins, installedPlugins) { + PluginLoader.getPlugins() + .mapNotNull { it.getManifest().getOrNull() } + .capabilityIdentities() } val filteredFlows = remember(state.flows, searchQuery) { @@ -250,8 +256,9 @@ fun FlowManagerView( val missingCapabilities = remember(flow, activeCapabilities) { flow.nodes.filterIsInstance() - .map { it.capability.name } + .map { CapabilityIdentity(it.pluginInfo.id, it.capability.name) } .filter { it !in activeCapabilities } + .map { it.capabilityName } .distinct() } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt index a3b3130c..db5ccfde 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/FlowRunnerView.kt @@ -45,7 +45,7 @@ import org.wip.plugintoolkit.api.format import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.flows.model.Node -import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity +import org.wip.plugintoolkit.features.flows.model.capabilityIdentities import org.wip.plugintoolkit.features.flows.viewmodel.FlowViewModel import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.job.model.JobType @@ -95,19 +95,16 @@ fun FlowRunnerView( val pluginManager: org.wip.plugintoolkit.features.plugin.logic.PluginManager = koinInject() val pluginLocksState by pluginManager.pluginLocksState.collectAsState() val pluginSettingsState by pluginManager.pluginSettingsState.collectAsState() + val loadedPlugins by pluginManager.loadedPlugins.collectAsState() + val installedPlugins by pluginManager.installedPlugins.collectAsState() val providedLocks = remember(pluginLocksState) { pluginLocksState.values.fold(emptyMap()) { acc, map -> acc + map } } - val activeCapabilities = remember(state.flows) { + val activeCapabilities = remember(loadedPlugins, installedPlugins) { org.wip.plugintoolkit.features.plugin.logic.PluginLoader.getPlugins() .mapNotNull { it.getManifest().getOrNull() } - .flatMap { manifest -> - manifest.capabilities.map { capability -> - CapabilityIdentity(manifest.plugin.id, capability.name) - } - } - .toSet() + .capabilityIdentities() } val executableFlows = remember(state.flows, activeCapabilities) { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt index 8f35db4d..cfc9b43c 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/viewmodel/FlowViewModel.kt @@ -27,7 +27,7 @@ import org.wip.plugintoolkit.features.flows.logic.FlowRepository import org.wip.plugintoolkit.features.flows.model.Connection import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.flows.model.Node -import org.wip.plugintoolkit.features.flows.model.CapabilityIdentity +import org.wip.plugintoolkit.features.flows.model.capabilityIdentities import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobStatus @@ -560,12 +560,7 @@ class FlowViewModel( fun executeFlow(flow: Flow, parameterValues: Map) { val activeCapabilities = org.wip.plugintoolkit.features.plugin.logic.PluginLoader.getPlugins() .mapNotNull { it.getManifest().getOrNull() } - .flatMap { manifest -> - manifest.capabilities.map { capability -> - CapabilityIdentity(manifest.plugin.id, capability.name) - } - } - .toSet() + .capabilityIdentities() if (flow.isBroken(activeCapabilities)) { Logger.e { "Failed to execute flow '${flow.name}': Flow contains broken or unready nodes." }