Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion mdl/executor/cmd_microflows_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -982,11 +982,13 @@ func findMergeForSplit(
}

branchDistances := make([]map[model.ID]int, 0, len(flows))
branchStarts := make([]model.ID, 0, len(flows))
for _, flow := range flows {
branchStarts = append(branchStarts, flow.DestinationID)
branchDistances = append(branchDistances, collectReachableDistances(flow.DestinationID, flowsByOrigin))
}

return selectNearestCommonJoin(activityMap, branchDistances)
return selectNearestCommonJoin(activityMap, flowsByOrigin, branchStarts, branchDistances)
}

// collectReachableDistances collects the shortest normal-flow distance from a
Expand Down Expand Up @@ -1025,6 +1027,8 @@ func collectReachableDistances(

func selectNearestCommonJoin(
activityMap map[model.ID]microflows.MicroflowObject,
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
branchStarts []model.ID,
branchDistances []map[model.ID]int,
) model.ID {
if len(branchDistances) < 2 {
Expand All @@ -1033,6 +1037,7 @@ func selectNearestCommonJoin(

type candidate struct {
id model.ID
reachCount int
maxDistance int
sumDistance int
}
Expand Down Expand Up @@ -1060,17 +1065,56 @@ func selectNearestCommonJoin(
if common {
candidates = append(candidates, candidate{
id: nodeID,
reachCount: len(branchDistances),
maxDistance: maxDistance,
sumDistance: sumDistance,
})
}
}

if len(candidates) == 0 {
byNode := map[model.ID]candidate{}
for _, distances := range branchDistances {
for nodeID, distance := range distances {
if !isSplitJoinCandidate(activityMap[nodeID]) {
continue
}
c := byNode[nodeID]
c.id = nodeID
c.reachCount++
if distance > c.maxDistance {
c.maxDistance = distance
}
c.sumDistance += distance
byNode[nodeID] = c
}
}
for _, c := range byNode {
if c.reachCount >= 2 {
candidates = append(candidates, c)
}
}
}

if len(candidates) == 0 {
return ""
}

filtered := candidates[:0]
for _, candidate := range candidates {
if splitJoinCandidateDoesNotHaveDownstreamBypass(candidate.id, activityMap, flowsByOrigin, branchStarts) {
filtered = append(filtered, candidate)
}
}
candidates = filtered
if len(candidates) == 0 {
return ""
}

sort.Slice(candidates, func(i, j int) bool {
if candidates[i].reachCount != candidates[j].reachCount {
return candidates[i].reachCount > candidates[j].reachCount
}
if candidates[i].maxDistance != candidates[j].maxDistance {
return candidates[i].maxDistance < candidates[j].maxDistance
}
Expand All @@ -1083,6 +1127,88 @@ func selectNearestCommonJoin(
return candidates[0].id
}

func splitJoinCandidateDoesNotHaveDownstreamBypass(
candidateID model.ID,
activityMap map[model.ID]microflows.MicroflowObject,
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
branchStarts []model.ID,
) bool {
downstream := collectReachableNonTerminalObjects(candidateID, activityMap, flowsByOrigin)
if len(downstream) == 0 {
return true
}
for _, startID := range branchStarts {
if startID == candidateID {
continue
}
if reachesAnyObjectAvoiding(startID, downstream, candidateID, activityMap, flowsByOrigin, map[model.ID]bool{}) {
return false
}
}
return true
}

func collectReachableNonTerminalObjects(
startID model.ID,
activityMap map[model.ID]microflows.MicroflowObject,
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
) map[model.ID]bool {
result := map[model.ID]bool{}
var walk func(model.ID)
visited := map[model.ID]bool{startID: true}
walk = func(currentID model.ID) {
if visited[currentID] {
return
}
visited[currentID] = true
if isNonTerminalMicroflowObject(activityMap[currentID]) {
result[currentID] = true
}
for _, flow := range findNormalFlows(flowsByOrigin[currentID]) {
walk(flow.DestinationID)
}
}
for _, flow := range findNormalFlows(flowsByOrigin[startID]) {
walk(flow.DestinationID)
}
return result
}

func reachesAnyObjectAvoiding(
currentID model.ID,
targets map[model.ID]bool,
avoidID model.ID,
activityMap map[model.ID]microflows.MicroflowObject,
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
visited map[model.ID]bool,
) bool {
if currentID == "" || currentID == avoidID || visited[currentID] {
return false
}
if targets[currentID] {
return true
}
if !isNonTerminalMicroflowObject(activityMap[currentID]) {
return false
}
visited[currentID] = true
for _, flow := range findNormalFlows(flowsByOrigin[currentID]) {
if reachesAnyObjectAvoiding(flow.DestinationID, targets, avoidID, activityMap, flowsByOrigin, visited) {
return true
}
}
return false
}

func isNonTerminalMicroflowObject(obj microflows.MicroflowObject) bool {
switch obj.(type) {
case nil, *microflows.StartEvent, *microflows.EndEvent, *microflows.ErrorEvent:
return false
default:
return true
}
}

func isSplitJoinCandidate(obj microflows.MicroflowObject) bool {
switch obj.(type) {
case nil, *microflows.StartEvent, *microflows.EndEvent:
Expand Down
85 changes: 68 additions & 17 deletions mdl/executor/cmd_microflows_show_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -668,12 +668,7 @@ func traverseFlow(
emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap)
emitEnumSplitStatement(ctx, currentID, mergeID, variable, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1)
if mergeID != "" {
visited[mergeID] = true
for _, flow := range flowsByOrigin[mergeID] {
traverseFlow(ctx, flow.DestinationID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
}
}
continueAfterSplitJoin(ctx, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
return
}
if stmt != "" {
Expand Down Expand Up @@ -831,12 +826,7 @@ func traverseFlowUntilMerge(
emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap)
emitEnumSplitStatement(ctx, currentID, nestedMergeID, variable, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1)
if nestedMergeID != "" && nestedMergeID != mergeID {
visited[nestedMergeID] = true
for _, flow := range flowsByOrigin[nestedMergeID] {
traverseFlowUntilMerge(ctx, flow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
}
}
continueAfterNestedSplitJoin(ctx, nestedMergeID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
return
}
if stmt != "" {
Expand All @@ -845,10 +835,11 @@ func traverseFlowUntilMerge(
}

trueFlow, falseFlow := findBranchFlows(flows)
nestedMergeID = resolveNestedMergeID(nestedMergeID, mergeID, trueFlow, falseFlow, flowsByOrigin)

// Empty-then swap: negate when true branch is empty but false branch has content.
// Skip when both branches go directly to merge (both empty).
if trueFlow != nil && falseFlow != nil && nestedMergeID != "" {
if trueFlow != nil && falseFlow != nil && nestedMergeID != "" && nestedMergeID == mergeID {
if trueFlow.DestinationID == nestedMergeID && falseFlow.DestinationID != nestedMergeID {
stmt = negateIfCondition(stmt)
trueFlow, falseFlow = falseFlow, trueFlow
Expand Down Expand Up @@ -1000,6 +991,63 @@ func continueAfterNestedSplitJoin(
traverseFlowUntilMerge(ctx, joinID, parentMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget)
}

func resolveNestedMergeID(
nestedMergeID model.ID,
parentMergeID model.ID,
trueFlow *microflows.SequenceFlow,
falseFlow *microflows.SequenceFlow,
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
) model.ID {
if nestedMergeID != "" && parentMergeID != "" && nestedMergeID != parentMergeID &&
canReachNode(parentMergeID, nestedMergeID, flowsByOrigin, make(map[model.ID]bool)) {
for _, flow := range []*microflows.SequenceFlow{trueFlow, falseFlow} {
if flow == nil {
continue
}
if flow.DestinationID == parentMergeID ||
canReachNode(flow.DestinationID, parentMergeID, flowsByOrigin, make(map[model.ID]bool)) {
return parentMergeID
}
}
}
if nestedMergeID != "" || parentMergeID == "" {
return nestedMergeID
}
for _, flow := range []*microflows.SequenceFlow{trueFlow, falseFlow} {
if flow == nil {
continue
}
if canReachNode(flow.DestinationID, parentMergeID, flowsByOrigin, make(map[model.ID]bool)) {
return parentMergeID
}
}
return ""
}

func canReachNode(
currentID model.ID,
targetID model.ID,
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
visited map[model.ID]bool,
) bool {
if currentID == "" {
return false
}
if currentID == targetID {
return true
}
if visited[currentID] {
return false
}
visited[currentID] = true
for _, flow := range findNormalFlows(flowsByOrigin[currentID]) {
if canReachNode(flow.DestinationID, targetID, flowsByOrigin, visited) {
return true
}
}
return false
}

// traverseLoopBody traverses activities inside a loop body.
// When sourceMap is non-nil, it also records line ranges for each activity node.
func traverseLoopBody(
Expand Down Expand Up @@ -1421,10 +1469,13 @@ func flowLooksLikeGuardContinuation(
return false
}
// Builder-generated guard continuations sit on the split's horizontal
// centerline. This intentionally relies on mxcli's layout contract so a
// real branch that returns to a merge below the split is not collapsed into
// a guard-style continuation during describe.
return dest.GetPosition().Y == split.GetPosition().Y
// centerline and use the builder's horizontal split→tail flow. This
// intentionally relies on mxcli's layout/anchor contract so a real false
// branch whose activities happen to be aligned with the split is not
// collapsed into a guard-style continuation during describe.
return dest.GetPosition().Y == split.GetPosition().Y &&
flow.OriginConnectionIndex == AnchorRight &&
flow.DestinationConnectionIndex == AnchorLeft
}

// findErrorHandlerFlow returns the error handler flow from an activity's outgoing flows.
Expand Down
Loading
Loading