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..3a196776 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 @@ -278,10 +279,11 @@ sealed class Node { override fun isReady(connections: List, settings: Map?): Boolean { if (isBroken) return false - val parameters = capability.parameters ?: return true + val parameters = capability.parameters.orEmpty() + val inputsById = inputs.associateBy { it.id } for ((portId, metadata) in parameters) { - val inputPort = inputs.find { it.id == portId } - val effectiveValue = inputPort?.value ?: inputPort?.defaultValue + val inputPort = inputsById[portId] + val effectiveValue = inputPort?.value ?: inputPort?.defaultValue ?: metadata.defaultValue if (metadata.type is DataType.Enum) { val enumType = metadata.type as DataType.Enum @@ -301,12 +303,13 @@ sealed class Node { if (metadata.required) { val providedByValue = - inputPort?.dataType?.isProvided(AnySerializer.toJsonElement(effectiveValue)) == true + metadata.type.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 ) @@ -490,10 +493,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 +526,16 @@ 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 064ebb82..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,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.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 @@ -94,16 +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 { it.capabilities } - .map { it.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 66f84fb7..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,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.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 @@ -559,9 +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 { it.capabilities } - .map { it.name } - .toSet() + .capabilityIdentities() if (flow.isBroken(activeCapabilities)) { Logger.e { "Failed to execute flow '${flow.name}': Flow contains broken or unready nodes." } 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/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..235dc750 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/FlowImprovementRegressionTest.kt @@ -0,0 +1,124 @@ +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.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 +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, includeMetadata: Boolean = true) = Node.CapabilityNode( + id = 2, + position = Offset.Zero, + pluginInfo = plugin, + 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 + ) + + @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)) + } + + @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)) + } + + @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)) + } + + @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())) + } +} 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 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.