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/4] 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 abdff6c1404a42acfb32784e36ad25c658e40386 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:46:38 +1000 Subject: [PATCH 2/4] Preserve flow readiness across manifest changes --- .../features/flows/model/NodeModels.kt | 20 ++--- .../flows/FlowImprovementRegressionTest.kt | 78 +++++++++++++++++++ docs/FlowEditor.md | 6 ++ 3 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt 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..c1ee38c4 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 @@ -278,12 +278,13 @@ sealed class Node { override fun isReady(connections: List, settings: Map?): Boolean { if (isBroken) return false - val parameters = capability.parameters ?: return true - for ((portId, metadata) in parameters) { - val inputPort = inputs.find { it.id == portId } - val effectiveValue = inputPort?.value ?: inputPort?.defaultValue + val parameters = capability.parameters.orEmpty() + for (inputPort in inputs) { + val portId = inputPort.id + val metadata = parameters[portId] + val effectiveValue = inputPort.value ?: inputPort.defaultValue - if (metadata.type is DataType.Enum) { + if (metadata?.type is DataType.Enum) { val enumType = metadata.type as DataType.Enum val selectedValueStr = effectiveValue?.let { if (it is JsonPrimitive) it.content else it.toString() @@ -299,14 +300,15 @@ sealed class Node { } } - if (metadata.required) { + if (metadata?.required ?: inputPort.isRequired) { val providedByValue = - inputPort?.dataType?.isProvided(AnySerializer.toJsonElement(effectiveValue)) == true + inputPort.dataType.isProvided(AnySerializer.toJsonElement(effectiveValue)) val providedByConnection = connections.any { it.targetNodeId == id && it.targetPortId == portId } - val canBeAutogenerated = metadata.autogeneratedPattern != null && + val autogeneratedPattern = metadata?.autogeneratedPattern + val canBeAutogenerated = autogeneratedPattern != null && PathPatternResolver.canResolve( - metadata.autogeneratedPattern!!, + autogeneratedPattern, parameters.keys ) diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt new file mode 100644 index 00000000..299cf485 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt @@ -0,0 +1,78 @@ +package org.wip.plugintoolkit.features.flows + +import androidx.compose.ui.geometry.Offset +import org.wip.plugintoolkit.api.Capability +import org.wip.plugintoolkit.api.DataType +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.InputPort +import org.wip.plugintoolkit.features.flows.model.Node +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FlowImprovementRegressionTest { + private val plugin = PluginInfo("example.plugin", "Example", "1.0.0", "Example plugin") + private val capability = Capability( + name = "Transform", + description = "Transforms a value", + returnType = DataType.Primitive(PrimitiveType.STRING) + ) + private val stringType = DataType.Primitive(PrimitiveType.STRING) + + private fun node(input: InputPort, isBroken: Boolean = false) = Node.CapabilityNode( + id = 2, + position = Offset.Zero, + pluginInfo = plugin, + capability = capability, + inputs = listOf(input), + outputs = emptyList(), + isBroken = isBroken + ) + + @Test + fun `required empty input makes node and flow not ready`() { + val input = InputPort("value", "Value", stringType, isRequired = true) + val flow = Flow("Required", nodes = listOf(node(input))) + + assertFalse(flow.nodes.single().isReady(flow.connections)) + assertTrue(flow.isBroken(setOf(capability.name))) + } + + @Test + fun `optional empty input remains ready`() { + val input = InputPort("value", "Value", stringType, isRequired = false) + val flow = Flow("Optional", nodes = listOf(node(input))) + + assertTrue(flow.nodes.single().isReady(flow.connections)) + assertFalse(flow.isBroken(setOf(capability.name))) + } + + @Test + fun `connection or default satisfies a required input`() { + val required = InputPort("value", "Value", stringType, isRequired = true) + val connectedFlow = Flow( + "Connected", + nodes = listOf(node(required)), + connections = listOf(Connection(1, "out", 2, "value")) + ) + val defaultedFlow = Flow( + "Defaulted", + nodes = listOf(node(required.copy(defaultValue = "fallback"))) + ) + + assertTrue(connectedFlow.nodes.single().isReady(connectedFlow.connections)) + assertTrue(defaultedFlow.nodes.single().isReady(defaultedFlow.connections)) + } + + @Test + fun `explicitly broken node remains not ready after parameter completion`() { + val configured = InputPort("value", "Value", stringType, value = "configured", isRequired = true) + val flow = Flow("Broken", nodes = listOf(node(configured, isBroken = true))) + + assertFalse(flow.nodes.single().isReady(flow.connections)) + assertTrue(flow.isBroken(setOf(capability.name))) + } +} diff --git a/docs/FlowEditor.md b/docs/FlowEditor.md index 8036703e..101c1eba 100644 --- a/docs/FlowEditor.md +++ b/docs/FlowEditor.md @@ -2,6 +2,12 @@ The CMP Desktop Application includes a powerful visual Flow Editor that allows you to orchestrate capabilities, system nodes, subflows, and inputs/outputs into cohesive workflows. This document details some of the advanced features available in the flow editor UI to manage complex diagrams effectively. +## Readiness and broken flows + +A capability node is ready when every required input has a direct value, a default, or an incoming connection. Optional inputs may remain empty. Nodes whose plugin or capability cannot be resolved stay broken even when their fields are populated. + +The editor marks unready nodes and required fields, while the runner excludes any flow containing a broken or unready node. Long flow and capability names remain available through their tooltips. + ## Collapsible Nodes To keep large flows readable, you can collapse nodes or individual sections of nodes. 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 3/4] 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." } From 18bc1e96dc0f620887f6739a99c39d796e3dc532 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:45:15 +1000 Subject: [PATCH 4/4] fix: reconcile manifest parameters with persisted ports --- .../features/flows/model/NodeModels.kt | 16 +++--- .../flows/FlowImprovementRegressionTest.kt | 56 +++++++++++++++++-- 2 files changed, 59 insertions(+), 13 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 c1ee38c4..2d1c3bbf 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 @@ -279,12 +279,12 @@ sealed class Node { override fun isReady(connections: List, settings: Map?): Boolean { if (isBroken) return false val parameters = capability.parameters.orEmpty() - for (inputPort in inputs) { - val portId = inputPort.id - val metadata = parameters[portId] - val effectiveValue = inputPort.value ?: inputPort.defaultValue + val inputsById = inputs.associateBy { it.id } + for ((portId, metadata) in parameters) { + val inputPort = inputsById[portId] + val effectiveValue = inputPort?.value ?: inputPort?.defaultValue ?: metadata.defaultValue - if (metadata?.type is DataType.Enum) { + if (metadata.type is DataType.Enum) { val enumType = metadata.type as DataType.Enum val selectedValueStr = effectiveValue?.let { if (it is JsonPrimitive) it.content else it.toString() @@ -300,12 +300,12 @@ sealed class Node { } } - if (metadata?.required ?: inputPort.isRequired) { + if (metadata.required) { val providedByValue = - inputPort.dataType.isProvided(AnySerializer.toJsonElement(effectiveValue)) + metadata.type.isProvided(AnySerializer.toJsonElement(effectiveValue)) val providedByConnection = connections.any { it.targetNodeId == id && it.targetPortId == portId } - val autogeneratedPattern = metadata?.autogeneratedPattern + val autogeneratedPattern = metadata.autogeneratedPattern val canBeAutogenerated = autogeneratedPattern != null && PathPatternResolver.canResolve( autogeneratedPattern, diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt index 299cf485..235dc750 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt @@ -5,6 +5,7 @@ import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.ParameterMetadata import org.wip.plugintoolkit.features.flows.model.Connection import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.flows.model.InputPort @@ -22,11 +23,21 @@ class FlowImprovementRegressionTest { ) private val stringType = DataType.Primitive(PrimitiveType.STRING) - private fun node(input: InputPort, isBroken: Boolean = false) = Node.CapabilityNode( + private fun node(input: InputPort, isBroken: Boolean = false, includeMetadata: Boolean = true) = Node.CapabilityNode( id = 2, position = Offset.Zero, pluginInfo = plugin, - capability = capability, + capability = capability.copy( + parameters = if (includeMetadata) { + mapOf( + input.id to ParameterMetadata( + description = input.name, + type = input.dataType, + required = input.isRequired + ) + ) + } else null + ), inputs = listOf(input), outputs = emptyList(), isBroken = isBroken @@ -38,7 +49,6 @@ class FlowImprovementRegressionTest { val flow = Flow("Required", nodes = listOf(node(input))) assertFalse(flow.nodes.single().isReady(flow.connections)) - assertTrue(flow.isBroken(setOf(capability.name))) } @Test @@ -47,7 +57,6 @@ class FlowImprovementRegressionTest { val flow = Flow("Optional", nodes = listOf(node(input))) assertTrue(flow.nodes.single().isReady(flow.connections)) - assertFalse(flow.isBroken(setOf(capability.name))) } @Test @@ -73,6 +82,43 @@ class FlowImprovementRegressionTest { val flow = Flow("Broken", nodes = listOf(node(configured, isBroken = true))) assertFalse(flow.nodes.single().isReady(flow.connections)) - assertTrue(flow.isBroken(setOf(capability.name))) + } + + @Test + fun `new required manifest parameter without persisted port is not ready`() { + val liveCapability = capability.copy( + parameters = mapOf( + "newValue" to ParameterMetadata( + description = "New required value", + type = stringType, + required = true + ) + ) + ) + val migratedNode = Node.CapabilityNode( + id = 2, + position = Offset.Zero, + pluginInfo = plugin, + capability = liveCapability, + inputs = emptyList(), + outputs = emptyList() + ) + + assertFalse(migratedNode.isReady(emptyList())) + } + + @Test + fun `stale persisted port removed from manifest is ignored`() { + val staleInput = InputPort("removed", "Removed", stringType, isRequired = true) + + assertTrue(node(staleInput, includeMetadata = false).isReady(emptyList())) + } + + @Test + fun `parameterless capability remains ready despite stale inputs`() { + val staleInput = InputPort("legacy", "Legacy", stringType, isRequired = true) + val parameterlessNode = node(staleInput, includeMetadata = false) + + assertTrue(parameterlessNode.isReady(emptyList())) } }