From 1e7da00de37a64c18b702c03a8329ee427a6a926 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 16:05:07 +0200 Subject: [PATCH 01/11] fix: preserve custom error handler continuations Symptom: custom error-handler bodies that did not explicitly return were rebuilt as detached terminal paths, while empty custom handlers could either lose their error flow or rejoin into statements that read an output variable that is absent on the error path. Root cause: the microflow builder handled custom error handlers immediately at the source activity. It did not retain pending handler state until the next safe continuation was known, and a later handler could overwrite an earlier pending handler. Fix: queue pending custom handler state, route non-terminal handlers through the next safe continuation via a merge, preserve empty custom-handler flows, and terminate output-producing handlers before output-dependent continuation statements in void microflows. Tests: added synthetic builder regressions for non-terminal handler rejoin, consecutive pending handlers, empty output-handler termination, and empty no-output-handler rejoin. Ran make build, make lint-go, and make test. --- mdl/executor/cmd_microflows_builder.go | 22 +- .../cmd_microflows_builder_actions.go | 28 +- .../cmd_microflows_builder_annotations.go | 1 + mdl/executor/cmd_microflows_builder_calls.go | 53 +-- mdl/executor/cmd_microflows_builder_flows.go | 420 ++++++++++++++++-- mdl/executor/cmd_microflows_builder_graph.go | 66 ++- .../cmd_microflows_builder_terminal_test.go | 245 ++++++++++ .../cmd_microflows_builder_workflow.go | 6 +- 8 files changed, 735 insertions(+), 106 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 99b5d724..94dbf221 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -23,8 +23,11 @@ type flowBuilder struct { posY int baseY int // Base Y position (for returning after ELSE branches) spacing int - returnValue string // Return value expression for RETURN statement (used by buildFlowGraph final EndEvent) + returnValue string // Return value expression for RETURN statement (used by buildFlowGraph final EndEvent) + returnType *ast.MicroflowReturnType + hasReturnValue bool // True when the microflow declares a non-void return type endsWithReturn bool // True if the flow already ends with EndEvent(s) from RETURN statements + lastReturnEndID model.ID // Last explicit RETURN EndEvent, used as a fallback error-handler target varTypes map[string]string // Variable name -> entity qualified name (for CHANGE statements) declaredVars map[string]string // Declared primitive variables: name -> type (e.g., "$IsValid" -> "Boolean") errors []string // Validation errors collected during build @@ -49,11 +52,18 @@ type flowBuilder struct { // be overridden by the user. Cleared after each flow is created. previousStmtAnchor *ast.FlowAnchors // Cached flow lists to avoid repeated backend calls during lookups. - microflowsCache []*microflows.Microflow - microflowsCacheLoaded bool - nanoflowsCache []*microflows.Nanoflow - nanoflowsCacheLoaded bool - manualLoopBackTarget model.ID + microflowsCache []*microflows.Microflow + microflowsCacheLoaded bool + nanoflowsCache []*microflows.Nanoflow + nanoflowsCacheLoaded bool + manualLoopBackTarget model.ID + emptyErrorHandlerFrom model.ID + errorHandlerTailFrom model.ID + errorHandlerSource model.ID + errorHandlerSkipVar string + errorHandlerTailIsSource bool + errorHandlerReturnValue string + pendingErrorHandlers []pendingErrorHandlerState } // addError records a validation error during flow building. diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index d3129851..2a38a9ae 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -131,12 +131,7 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.Variable) return activity.ID } @@ -167,12 +162,7 @@ func (fb *flowBuilder) addCommitAction(s *ast.MfCommitStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } @@ -201,12 +191,7 @@ func (fb *flowBuilder) addDeleteAction(s *ast.DeleteObjectStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } @@ -468,12 +453,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.Variable) return activity.ID } diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 9e53aeae..d2a1acf3 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -225,6 +225,7 @@ func (fb *flowBuilder) addEndEventWithReturn(s *ast.ReturnStmt) model.ID { fb.objects = append(fb.objects, endEvent) fb.endsWithReturn = true + fb.lastReturnEndID = endEvent.ID fb.posX += fb.spacing / 2 return endEvent.ID } diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index eb9cb6a9..92474303 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -158,12 +158,7 @@ func (fb *flowBuilder) addCallMicroflowAction(s *ast.CallMicroflowStmt) model.ID fb.registerResultVariableType(s.OutputVariable, fb.lookupMicroflowReturnType(mfQN)) } - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -427,12 +422,7 @@ func (fb *flowBuilder) addCallJavaScriptActionAction(s *ast.CallJavaScriptAction fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -478,12 +468,7 @@ func (fb *flowBuilder) addCallExternalActionAction(s *ast.CallExternalActionStmt fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -978,12 +963,7 @@ func (fb *flowBuilder) addRestCallAction(s *ast.RestCallStmt) model.ID { fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1187,12 +1167,7 @@ func (fb *flowBuilder) addExecuteDatabaseQueryAction(s *ast.ExecuteDatabaseQuery fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1259,11 +1234,7 @@ func (fb *flowBuilder) addImportFromMappingAction(s *ast.ImportFromMappingStmt) } } - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -1295,11 +1266,7 @@ func (fb *flowBuilder) addTransformJsonAction(s *ast.TransformJsonStmt) model.ID fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } @@ -1333,11 +1300,7 @@ func (fb *flowBuilder) addExportToMappingAction(s *ast.ExportToMappingStmt) mode fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, "") return activity.ID } diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 9077fb6a..28304742 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -29,6 +29,377 @@ func convertErrorHandlingType(eh *ast.ErrorHandlingClause) microflows.ErrorHandl } } +func isEmptyCustomErrorHandler(eh *ast.ErrorHandlingClause) bool { + if eh == nil || len(eh.Body) != 0 { + return false + } + return eh.Type == ast.ErrorHandlingCustom || eh.Type == ast.ErrorHandlingCustomWithoutRollback +} + +func (fb *flowBuilder) finishCustomErrorHandler(activityID model.ID, activityX int, eh *ast.ErrorHandlingClause, outputVar string) { + if eh == nil { + return + } + if len(eh.Body) > 0 { + errorY := fb.posY + VerticalSpacing + mergeID := fb.addErrorHandlerFlow(activityID, activityX, eh.Body) + fb.handleErrorHandlerMergeWithSkip(mergeID, activityID, errorY, outputVar) + return + } + fb.registerEmptyCustomErrorHandlerWithSkip(activityID, eh, outputVar) +} + +func (fb *flowBuilder) registerEmptyCustomErrorHandlerWithSkip(activityID model.ID, eh *ast.ErrorHandlingClause, skipVar string) { + if !isEmptyCustomErrorHandler(eh) { + return + } + fb.queueActivePendingErrorHandler() + if skipVar == "" { + fb.emptyErrorHandlerFrom = activityID + return + } + fb.errorHandlerSource = activityID + fb.errorHandlerTailFrom = activityID + fb.errorHandlerSkipVar = skipVar + fb.errorHandlerTailIsSource = true +} + +type pendingErrorHandlerState struct { + emptyFrom model.ID + tailFrom model.ID + source model.ID + skipVar string + tailIsSource bool + returnValue string +} + +func (s pendingErrorHandlerState) activeIsEmpty() bool { + return s.emptyFrom == "" && s.tailFrom == "" && s.source == "" && s.skipVar == "" +} + +func (fb *flowBuilder) activePendingErrorHandler() pendingErrorHandlerState { + return pendingErrorHandlerState{ + emptyFrom: fb.emptyErrorHandlerFrom, + tailFrom: fb.errorHandlerTailFrom, + source: fb.errorHandlerSource, + skipVar: fb.errorHandlerSkipVar, + tailIsSource: fb.errorHandlerTailIsSource, + returnValue: fb.errorHandlerReturnValue, + } +} + +func (fb *flowBuilder) setActivePendingErrorHandler(state pendingErrorHandlerState) { + fb.emptyErrorHandlerFrom = state.emptyFrom + fb.errorHandlerTailFrom = state.tailFrom + fb.errorHandlerSource = state.source + fb.errorHandlerSkipVar = state.skipVar + fb.errorHandlerTailIsSource = state.tailIsSource + fb.errorHandlerReturnValue = state.returnValue +} + +func (fb *flowBuilder) queueActivePendingErrorHandler() { + state := fb.activePendingErrorHandler() + if state.activeIsEmpty() { + return + } + fb.pendingErrorHandlers = append(fb.pendingErrorHandlers, state) + fb.setActivePendingErrorHandler(pendingErrorHandlerState{}) +} + +func (fb *flowBuilder) rewritePendingErrorHandlers(rewrite func(pendingErrorHandlerState) pendingErrorHandlerState) { + queue := fb.pendingErrorHandlers[:0] + for _, state := range fb.pendingErrorHandlers { + state = rewrite(state) + if !state.activeIsEmpty() { + queue = append(queue, state) + } + } + fb.pendingErrorHandlers = queue + + active := rewrite(fb.activePendingErrorHandler()) + fb.setActivePendingErrorHandler(active) +} + +func (fb *flowBuilder) addPendingErrorHandlerFlowForStatement(originID, destinationID model.ID, stmt ast.MicroflowStatement, futureReferencesSkipVar ...bool) { + futureReferences := len(futureReferencesSkipVar) > 0 && futureReferencesSkipVar[0] + fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState { + return fb.addPendingErrorHandlerFlowForState(state, originID, destinationID, stmt, futureReferences) + }) +} + +func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHandlerState, originID, destinationID model.ID, stmt ast.MicroflowStatement, futureReferencesSkipVar bool) pendingErrorHandlerState { + if destinationID == "" { + return state + } + if state.emptyFrom != "" { + if state.emptyFrom != originID { + return state + } + fb.addEmptyErrorHandlerRejoinFlowFrom(originID, state.emptyFrom, destinationID) + state.emptyFrom = "" + } + if state.tailFrom == "" { + return state + } + if state.source != "" && destinationID == state.source { + return state + } + if state.skipVar != "" { + if statementReferencesVar(stmt, state.skipVar) { + if !fb.hasReturnValue { + endID := fb.addTerminalEndEventForPendingHandler(fb.returnType, "") + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, endID)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, endID)) + } + return pendingErrorHandlerState{} + } + return state + } + if futureReferencesSkipVar { + return state + } + fb.addErrorHandlerRejoinFlowForState(state, originID, destinationID) + return pendingErrorHandlerState{} + } + if state.source != "" && state.source == originID { + fb.addErrorHandlerRejoinFlowForState(state, originID, destinationID) + return pendingErrorHandlerState{} + } + return state +} + +func (fb *flowBuilder) addEmptyErrorHandlerRejoinFlowFrom(normalOriginID, errorOriginID, destinationID model.ID) { + existingIdx := -1 + for i := len(fb.flows) - 1; i >= 0; i-- { + flow := fb.flows[i] + if !flow.IsErrorHandler && flow.OriginID == normalOriginID && flow.DestinationID == destinationID { + existingIdx = i + break + } + } + if existingIdx == -1 { + if mergeID := fb.findExistingRejoinMerge(normalOriginID, destinationID); mergeID != "" { + fb.flows = append(fb.flows, newErrorHandlerFlow(errorOriginID, mergeID)) + return + } + fb.flows = append(fb.flows, newErrorHandlerFlow(errorOriginID, destinationID)) + return + } + + existing := fb.flows[existingIdx] + fb.flows = append(fb.flows[:existingIdx], fb.flows[existingIdx+1:]...) + + merge := µflows.ExclusiveMerge{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX - HorizontalSpacing/2, Y: fb.baseY}, + Size: model.Size{Width: MergeSize, Height: MergeSize}, + }, + } + fb.objects = append(fb.objects, merge) + + normalFlow := newHorizontalFlow(normalOriginID, merge.ID) + normalFlow.OriginConnectionIndex = existing.OriginConnectionIndex + normalFlow.CaseValue = existing.CaseValue + fb.flows = append(fb.flows, normalFlow) + fb.flows = append(fb.flows, newErrorHandlerFlow(errorOriginID, merge.ID)) + + mergeFlow := newHorizontalFlow(merge.ID, destinationID) + mergeFlow.DestinationConnectionIndex = existing.DestinationConnectionIndex + fb.flows = append(fb.flows, mergeFlow) +} + +func (fb *flowBuilder) addErrorHandlerRejoinFlowForState(state pendingErrorHandlerState, originID, destinationID model.ID) { + existingIdx := -1 + for i := len(fb.flows) - 1; i >= 0; i-- { + flow := fb.flows[i] + if !flow.IsErrorHandler && flow.OriginID == originID && flow.DestinationID == destinationID { + existingIdx = i + break + } + } + if existingIdx == -1 { + if mergeID := fb.findExistingRejoinMerge(originID, destinationID); mergeID != "" { + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, mergeID)) + } else { + fb.flows = append(fb.flows, newUpwardFlow(state.tailFrom, mergeID)) + } + return + } + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, destinationID)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, destinationID)) + } + return + } + + existing := fb.flows[existingIdx] + fb.flows = append(fb.flows[:existingIdx], fb.flows[existingIdx+1:]...) + + merge := µflows.ExclusiveMerge{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX - HorizontalSpacing/2, Y: fb.baseY}, + Size: model.Size{Width: MergeSize, Height: MergeSize}, + }, + } + fb.objects = append(fb.objects, merge) + + normalFlow := newHorizontalFlow(originID, merge.ID) + normalFlow.OriginConnectionIndex = existing.OriginConnectionIndex + normalFlow.CaseValue = existing.CaseValue + fb.flows = append(fb.flows, normalFlow) + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, merge.ID)) + } else { + fb.flows = append(fb.flows, newUpwardFlow(state.tailFrom, merge.ID)) + } + + mergeFlow := newHorizontalFlow(merge.ID, destinationID) + mergeFlow.DestinationConnectionIndex = existing.DestinationConnectionIndex + fb.flows = append(fb.flows, mergeFlow) +} + +func (fb *flowBuilder) findExistingRejoinMerge(originID, destinationID model.ID) model.ID { + for _, flow := range fb.flows { + if flow.OriginID != originID || flow.IsErrorHandler { + continue + } + if !fb.isExclusiveMerge(flow.DestinationID) { + continue + } + for _, mergeFlow := range fb.flows { + if mergeFlow.OriginID == flow.DestinationID && mergeFlow.DestinationID == destinationID && !mergeFlow.IsErrorHandler { + return flow.DestinationID + } + } + } + return "" +} + +func (fb *flowBuilder) isExclusiveMerge(id model.ID) bool { + for _, obj := range fb.objects { + if obj.GetID() != id { + continue + } + _, ok := obj.(*microflows.ExclusiveMerge) + return ok + } + return false +} + +func statementReferencesVar(stmt ast.MicroflowStatement, varName string) bool { + if stmt == nil || varName == "" { + return false + } + for _, ref := range statementVarRefs(stmt) { + if ref == varName { + return true + } + } + return false +} + +func statementsReferenceVar(stmts []ast.MicroflowStatement, varName string) bool { + if varName == "" { + return false + } + for _, stmt := range stmts { + if statementReferencesVar(stmt, varName) { + return true + } + } + return false +} + +func statementVarRefs(stmt ast.MicroflowStatement) []string { + var refs []string + switch s := stmt.(type) { + case *ast.ReturnStmt: + refs = append(refs, exprVarRefs(s.Value)...) + case *ast.LogStmt: + refs = append(refs, exprVarRefs(s.Node)...) + refs = append(refs, exprVarRefs(s.Message)...) + for _, param := range s.Template { + refs = append(refs, exprVarRefs(param.Value)...) + } + case *ast.IfStmt: + refs = append(refs, exprVarRefs(s.Condition)...) + refs = append(refs, statementsVarRefs(s.ThenBody)...) + refs = append(refs, statementsVarRefs(s.ElseBody)...) + case *ast.WhileStmt: + refs = append(refs, exprVarRefs(s.Condition)...) + refs = append(refs, statementsVarRefs(s.Body)...) + case *ast.LoopStmt: + refs = append(refs, s.ListVariable) + refs = append(refs, statementsVarRefs(s.Body)...) + case *ast.MfSetStmt: + refs = append(refs, extractVarName(s.Target)) + refs = append(refs, exprVarRefs(s.Value)...) + case *ast.ChangeObjectStmt: + refs = append(refs, s.Variable) + for _, change := range s.Changes { + refs = append(refs, exprVarRefs(change.Value)...) + } + case *ast.CreateObjectStmt: + for _, change := range s.Changes { + refs = append(refs, exprVarRefs(change.Value)...) + } + case *ast.RetrieveStmt: + if s.StartVariable != "" { + refs = append(refs, s.StartVariable) + } + refs = append(refs, exprVarRefs(s.Where)...) + case *ast.CallMicroflowStmt: + for _, arg := range s.Arguments { + refs = append(refs, exprVarRefs(arg.Value)...) + } + case *ast.CallJavaActionStmt: + for _, arg := range s.Arguments { + refs = append(refs, exprVarRefs(arg.Value)...) + } + case *ast.RestCallStmt: + refs = append(refs, exprVarRefs(s.URL)...) + for _, param := range s.URLParams { + refs = append(refs, exprVarRefs(param.Value)...) + } + for _, header := range s.Headers { + refs = append(refs, exprVarRefs(header.Value)...) + } + if s.Body != nil { + refs = append(refs, exprVarRefs(s.Body.Template)...) + for _, param := range s.Body.TemplateParams { + refs = append(refs, exprVarRefs(param.Value)...) + } + if s.Body.SourceVariable != "" { + refs = append(refs, s.Body.SourceVariable) + } + } + refs = append(refs, exprVarRefs(s.Timeout)...) + case *ast.MfCommitStmt: + refs = append(refs, s.Variable) + case *ast.DeleteObjectStmt: + refs = append(refs, s.Variable) + case *ast.AddToListStmt: + refs = append(refs, s.Item, s.List) + case *ast.RemoveFromListStmt: + refs = append(refs, s.Item, s.List) + } + return refs +} + +func statementsVarRefs(stmts []ast.MicroflowStatement) []string { + var refs []string + for _, stmt := range stmts { + refs = append(refs, statementVarRefs(stmt)...) + } + return refs +} + // newErrorHandlerFlow creates a SequenceFlow with IsErrorHandler=true, // connecting from the bottom of the source activity to the left of the error handler. func newErrorHandlerFlow(originID, destinationID model.ID) *microflows.SequenceFlow { @@ -37,7 +408,7 @@ func newErrorHandlerFlow(originID, destinationID model.ID) *microflows.SequenceF OriginID: originID, DestinationID: destinationID, OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorTop, IsErrorHandler: true, } } @@ -57,16 +428,18 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // Build error handler activities errBuilder := &flowBuilder{ - posX: errorX, - posY: errorY, - baseY: errorY, - spacing: HorizontalSpacing, - varTypes: fb.varTypes, - declaredVars: fb.declaredVars, - measurer: fb.measurer, - backend: fb.backend, - hierarchy: fb.hierarchy, - restServices: fb.restServices, + posX: errorX, + posY: errorY, + baseY: errorY, + spacing: HorizontalSpacing, + returnType: fb.returnType, + hasReturnValue: fb.hasReturnValue, + varTypes: fb.varTypes, + declaredVars: fb.declaredVars, + measurer: fb.measurer, + backend: fb.backend, + hierarchy: fb.hierarchy, + restServices: fb.restServices, } var lastErrID model.ID @@ -105,23 +478,20 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // This is a fallback until full merge support is implemented. Caller should pass // the ID returned by addErrorHandlerFlow and the error handler Y position. func (fb *flowBuilder) handleErrorHandlerMerge(lastErrID model.ID, activityID model.ID, errorY int) { + fb.handleErrorHandlerMergeWithSkip(lastErrID, activityID, errorY, "") +} + +func (fb *flowBuilder) handleErrorHandlerMergeWithSkip(lastErrID model.ID, activityID model.ID, errorY int, skipVar string) { if lastErrID == "" { return // No merge needed (error handler terminates with RETURN or RAISE ERROR) } - // Error handler doesn't end with RETURN/RAISE — create EndEvent to terminate the path. - // When the microflow has a return type, use the return value from a prior RETURN statement - // if available to avoid "Return value required" errors. If no RETURN has been seen yet, - // fall back to empty (works for void microflows). - endEvent := µflows.EndEvent{ - BaseMicroflowObject: microflows.BaseMicroflowObject{ - BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, - Position: model.Point{X: fb.posX, Y: errorY}, - Size: model.Size{Width: EventSize, Height: EventSize}, - }, - ReturnValue: fb.returnValue, - } - fb.objects = append(fb.objects, endEvent) - fb.flows = append(fb.flows, newHorizontalFlow(lastErrID, endEvent.ID)) + _ = errorY + fb.queueActivePendingErrorHandler() + fb.errorHandlerSource = activityID + fb.errorHandlerTailFrom = lastErrID + fb.errorHandlerSkipVar = skipVar + fb.errorHandlerTailIsSource = false + fb.errorHandlerReturnValue = fb.returnValue } // newHorizontalFlow creates a SequenceFlow with anchors for horizontal left-to-right connection diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index f2c421c7..435f3414 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -27,9 +27,11 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a fb.objectInputVariables = collectObjectInputVariables(stmts) } // Set return value expression for error handler EndEvents + fb.returnType = returns if returns != nil && returns.Variable != "" { fb.returnValue = "$" + returns.Variable } + fb.hasReturnValue = returns != nil && returns.Type.Kind != ast.TypeVoid // Set baseY for branch restoration (this is the center line) fb.baseY = fb.posY @@ -64,7 +66,7 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // deferred split→nextActivity flow honours @anchor(true: ..., false: ...). pendingCase := "" var pendingFlowAnchor *ast.FlowAnchors - for _, stmt := range stmts { + for i, stmt := range stmts { // Snapshot the current statement's anchor annotation before addStatement // can reset pendingAnnotations via recursive processing. The incoming // side (To) is applied when this statement is the destination of the @@ -94,6 +96,7 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a pendingFlowAnchor = nil applyUserAnchors(flow, originAnchor, destAnchor) fb.flows = append(fb.flows, flow) + fb.addPendingErrorHandlerFlowForStatement(lastID, activityID, stmt, statementsReferenceVar(stmts[i+1:], fb.errorHandlerSkipVar)) fb.previousStmtAnchor = stmtAnchor // For compound statements (IF, LOOP), the exit point differs from entry point @@ -155,7 +158,15 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a } applyUserAnchors(endFlow, originAnchor, nil) fb.flows = append(fb.flows, endFlow) + var endStmt ast.MicroflowStatement + if len(fb.returnValue) > 1 && fb.returnValue[0] == '$' { + endStmt = &ast.ReturnStmt{Value: &ast.VariableExpr{Name: fb.returnValue[1:]}} + } + fb.addPendingErrorHandlerFlowForStatement(lastID, endEvent.ID, endStmt) + fb.terminatePendingErrorHandlersAtEnd(returns) fb.previousStmtAnchor = nil + } else { + fb.terminatePendingErrorHandlersAtEnd(returns) } return µflows.MicroflowObjectCollection{ @@ -417,6 +428,59 @@ func sourceAttributeVarRefs(source string) []string { return refs } +func (fb *flowBuilder) terminatePendingErrorHandlersAtEnd(returns *ast.MicroflowReturnType) { + fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState { + if state.emptyFrom != "" { + if returns != nil && returns.Type.Kind != ast.TypeVoid && fb.lastReturnEndID != "" { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.emptyFrom, fb.lastReturnEndID)) + } else { + endID := fb.addTerminalEndEventForPendingHandler(returns, state.returnValue) + fb.flows = append(fb.flows, newErrorHandlerFlow(state.emptyFrom, endID)) + } + state.emptyFrom = "" + state.returnValue = "" + } + if state.tailFrom != "" { + if returns != nil && returns.Type.Kind != ast.TypeVoid && state.returnValue == "" && fb.lastReturnEndID != "" { + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, fb.lastReturnEndID)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, fb.lastReturnEndID)) + } + } else { + endID := fb.addTerminalEndEventForPendingHandler(returns, state.returnValue) + if state.tailIsSource { + fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, endID)) + } else { + fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, endID)) + } + } + state.source = "" + state.tailFrom = "" + state.skipVar = "" + state.tailIsSource = false + state.returnValue = "" + } + return state + }) +} + +func (fb *flowBuilder) addTerminalEndEventForPendingHandler(returns *ast.MicroflowReturnType, returnValue string) model.ID { + if returnValue == "" && returns != nil && returns.Type.Kind != ast.TypeVoid { + returnValue = fb.returnValue + } + end := µflows.EndEvent{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Position: model.Point{X: fb.posX + HorizontalSpacing/2, Y: fb.baseY + VerticalSpacing}, + Size: model.Size{Width: EventSize, Height: EventSize}, + }, + ReturnValue: returnValue, + } + fb.objects = append(fb.objects, end) + return end.ID +} + // addStatement converts an AST statement to a microflow activity and returns its ID. func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID { // Extract annotations from the statement and merge into pendingAnnotations diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index 2829e092..be1e73eb 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/microflows" ) @@ -247,3 +248,247 @@ func TestBuildFlowGraph_ManualWhileTrueTerminalDoesNotAddFallthroughEnd(t *testi } } } + +func TestBuildFlowGraph_NonTerminalCustomHandlerRejoinsContinuation(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RefreshExternalData"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "refresh failed"}}, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "ContinueWithNextBatch"}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var sourceID, handlerLogID, nextID model.ID + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.MicroflowCall != nil && action.MicroflowCall.Microflow == "SampleSync.RefreshExternalData" { + sourceID = activity.ID + } + if action.MicroflowCall != nil && action.MicroflowCall.Microflow == "SampleSync.ContinueWithNextBatch" { + nextID = activity.ID + } + case *microflows.LogMessageAction: + if action.LogLevel == "Error" { + handlerLogID = activity.ID + } + } + } + if sourceID == "" || handlerLogID == "" || nextID == "" { + t.Fatalf("expected source call, handler log, and continuation; got source=%q log=%q next=%q", sourceID, handlerLogID, nextID) + } + if !flowPathExists(oc.Flows, handlerLogID, nextID) { + t.Fatal("non-terminal custom error handler should rejoin the next safe continuation") + } + for _, flow := range oc.Flows { + if flow.IsErrorHandler && flow.OriginID == sourceID && flow.DestinationID == nextID { + t.Fatal("custom handler must execute its body before rejoining") + } + } +} + +func TestBuildFlowGraph_ConsecutiveCustomHandlersEachRejoinsContinuation(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RetryFirstBatch"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "first failed"}}, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RetrySecondBatch"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "second failed"}}, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "RetryFinalBatch"}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + callIDs := map[string]model.ID{} + logIDs := map[string]model.ID{} + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.MicroflowCall != nil { + callIDs[action.MicroflowCall.Microflow] = activity.ID + } + case *microflows.LogMessageAction: + if action.MessageTemplate != nil { + logIDs[action.MessageTemplate.Translations["en_US"]] = activity.ID + } + } + } + + firstLog := logIDs["first failed"] + secondLog := logIDs["second failed"] + secondCall := callIDs["SampleSync.RetrySecondBatch"] + finalCall := callIDs["SampleSync.RetryFinalBatch"] + if firstLog == "" || secondLog == "" || secondCall == "" || finalCall == "" { + t.Fatalf("expected all handler logs and continuation calls; logs=%#v calls=%#v", logIDs, callIDs) + } + if !flowPathExists(oc.Flows, firstLog, secondCall) { + t.Fatal("first pending handler must rejoin before the second call instead of being overwritten") + } + if !flowPathExists(oc.Flows, secondLog, finalCall) { + t.Fatal("second pending handler must rejoin before the final continuation") + } +} + +func TestBuildFlowGraph_EmptyOutputHandlerTerminatesBeforeOutputDependentTail(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallJavaActionStmt{ + OutputVariable: "ProcessedCount", + ActionName: ast.QualifiedName{Module: "SampleMigration", Name: "CountProcessedItems"}, + ErrorHandling: &ast.ErrorHandlingClause{Type: ast.ErrorHandlingCustomWithoutRollback}, + }, + &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "processed {1}"}, + Template: []ast.TemplateParam{{ + Index: 1, + Value: &ast.VariableExpr{Name: "ProcessedCount"}, + }}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var javaID, logID model.ID + endIDs := map[model.ID]bool{} + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ActionActivity: + switch action := o.Action.(type) { + case *microflows.JavaActionCallAction: + if action.ResultVariableName == "ProcessedCount" { + javaID = o.ID + } + case *microflows.LogMessageAction: + if action.MessageTemplate != nil && action.MessageTemplate.Translations["en_US"] == "processed {1}" { + logID = o.ID + } + } + case *microflows.EndEvent: + endIDs[o.ID] = true + } + } + if javaID == "" || logID == "" || len(endIDs) == 0 { + t.Fatalf("expected java action, output-dependent log, and end event; got java=%q log=%q ends=%v", javaID, logID, endIDs) + } + + var errorFlowTerminates bool + for _, flow := range oc.Flows { + if !flow.IsErrorHandler || flow.OriginID != javaID { + continue + } + if flowPathExists(oc.Flows, flow.DestinationID, logID) { + t.Fatal("empty output handler must not rejoin before a statement that reads the missing output") + } + for endID := range endIDs { + if flow.DestinationID == endID || flowPathExists(oc.Flows, flow.DestinationID, endID) { + errorFlowTerminates = true + } + } + } + if !errorFlowTerminates { + t.Fatal("empty output handler should terminate at an EndEvent before the output-dependent tail") + } +} + +func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "SampleMigration", Name: "RefreshCache"}, + ErrorHandling: &ast.ErrorHandlingClause{Type: ast.ErrorHandlingCustomWithoutRollback}, + }, + &ast.CallJavaActionStmt{ + OutputVariable: "ProcessedCount", + ActionName: ast.QualifiedName{Module: "SampleMigration", Name: "CountProcessedItems"}, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var callID, javaID model.ID + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.MicroflowCall != nil && action.MicroflowCall.Microflow == "SampleMigration.RefreshCache" { + callID = activity.ID + } + case *microflows.JavaActionCallAction: + if action.ResultVariableName == "ProcessedCount" { + javaID = activity.ID + } + } + } + if callID == "" || javaID == "" { + t.Fatalf("expected no-output call and output-producing java action; got call=%q java=%q", callID, javaID) + } + for _, flow := range oc.Flows { + if flow.IsErrorHandler && flow.OriginID == callID && flowPathExists(oc.Flows, flow.DestinationID, javaID) { + return + } + } + t.Fatal("empty no-output handler should rejoin at the next action") +} + +func flowPathExists(flows []*microflows.SequenceFlow, startID, targetID model.ID) bool { + if startID == "" || targetID == "" { + return false + } + seen := map[model.ID]bool{} + queue := []model.ID{startID} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if id == targetID { + return true + } + if seen[id] { + continue + } + seen[id] = true + for _, flow := range flows { + if flow.OriginID == id && !seen[flow.DestinationID] { + queue = append(queue, flow.DestinationID) + } + } + } + return false +} diff --git a/mdl/executor/cmd_microflows_builder_workflow.go b/mdl/executor/cmd_microflows_builder_workflow.go index b25adb94..f43c47e0 100644 --- a/mdl/executor/cmd_microflows_builder_workflow.go +++ b/mdl/executor/cmd_microflows_builder_workflow.go @@ -26,11 +26,7 @@ func (fb *flowBuilder) wrapAction(action microflows.MicroflowAction, errorHandli fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - if errorHandling != nil && len(errorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, errorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, errorHandling, "") return activity.ID } From bd5d490b15d0c371baab801dee0d11ecc95d0849 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 18:36:32 +0200 Subject: [PATCH 02/11] test: add bug-test reproducer for custom error handler routing Adds an MDL script under mdl-examples/bug-tests/ exercising a non-terminal custom error handler on a microflow call followed by a continuation call. After exec, `mx check` reports 0 errors, confirming the handler tail rejoins the continuation instead of becoming a detached terminal path. Co-Authored-By: Claude Opus 4.7 --- .../349-custom-error-handler-routing.mdl | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 mdl-examples/bug-tests/349-custom-error-handler-routing.mdl diff --git a/mdl-examples/bug-tests/349-custom-error-handler-routing.mdl b/mdl-examples/bug-tests/349-custom-error-handler-routing.mdl new file mode 100644 index 00000000..2acc97b1 --- /dev/null +++ b/mdl-examples/bug-tests/349-custom-error-handler-routing.mdl @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Bug #349: Custom error-handler routing during microflow roundtrip +-- ============================================================================ +-- +-- Symptom (before fix): +-- Custom error-handler bodies that did NOT explicitly return were +-- rebuilt as DETACHED terminal paths — they got their own EndEvent and +-- never rejoined the next activity. Empty custom handlers could either +-- lose their error flow entirely or rejoin into statements that read +-- an output variable absent on the error path. +-- +-- Root cause: +-- The microflow builder handled custom error handlers immediately at +-- the source activity. It did not retain pending handler state until +-- the next safe continuation was known, so a later handler could +-- overwrite an earlier pending handler. +-- +-- After fix: +-- - Pending custom-handler state is queued. +-- - Non-terminal handler bodies rejoin through a merge before the +-- next safe continuation, instead of fabricating detached EndEvents. +-- - Empty custom-handler error flows are preserved. +-- - Output-producing handlers terminate before output-dependent +-- continuation in void microflows. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/349-custom-error-handler-routing.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest349.MF_Caller" +-- `mx check` against the resulting MPR must report 0 errors and the +-- non-terminal handler must rejoin the continuation activity. +-- ============================================================================ + +create module BugTest349; + +create microflow BugTest349.MF_RefreshData ( + $Token: string +) +begin + log info node 'BugTest349' 'refreshed: ' + $Token; +end; +/ + +create microflow BugTest349.MF_NextBatch () +begin + log info node 'BugTest349' 'next batch'; +end; +/ + +-- Caller with non-terminal custom error handler. The handler body logs +-- but does not return, so its tail must rejoin the continuation +-- (`call microflow ... MF_NextBatch ()`) instead of becoming a detached +-- terminal path. +create microflow BugTest349.MF_Caller ( + $Token: string +) +begin + call microflow BugTest349.MF_RefreshData (Token = $Token) + on error without rollback { + log error node 'BugTest349' 'refresh failed'; + }; + + call microflow BugTest349.MF_NextBatch (); +end; +/ From db665b9ade6c76284df2916240135b1a7f1e30b0 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 22:42:57 +0200 Subject: [PATCH 03/11] fix: keep output handlers away from declare dependencies Symptom: a custom error handler for an output-producing action could rejoin before a later DECLARE whose initial value referenced that output variable. Studio Pro then rejected the roundtripped microflow because the output variable is not in scope on the error-handler path. Root cause: skip-variable routing collected references from many statement kinds, but omitted DECLARE initial values. Fix: include DECLARE initial values in statement reference analysis so handlers wait for, or terminate before, output-dependent declarations. Tests: add a graph-level regression that verifies the error-handler path cannot reach an output-dependent DECLARE, plus the existing custom-handler routing tests via make build and make test. --- mdl/executor/cmd_microflows_builder_flows.go | 2 + .../cmd_microflows_builder_terminal_test.go | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 28304742..38ed2277 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -319,6 +319,8 @@ func statementsReferenceVar(stmts []ast.MicroflowStatement, varName string) bool func statementVarRefs(stmt ast.MicroflowStatement) []string { var refs []string switch s := stmt.(type) { + case *ast.DeclareStmt: + refs = append(refs, exprVarRefs(s.InitialValue)...) case *ast.ReturnStmt: refs = append(refs, exprVarRefs(s.Value)...) case *ast.LogStmt: diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index be1e73eb..af103ca3 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -425,6 +425,78 @@ func TestBuildFlowGraph_EmptyOutputHandlerTerminatesBeforeOutputDependentTail(t } } +func TestBuildFlowGraph_OutputHandlerTerminatesBeforeDeclareReferencingOutput(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + OutputVariable: "CreatedRecord", + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "CreateRecord"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogError, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "create failed"}}, + }, + }, + }, + &ast.DeclareStmt{ + Variable: "SuccessMessage", + Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.BinaryExpr{ + Left: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "Created "}, + Operator: "+", + Right: &ast.AttributePathExpr{Variable: "CreatedRecord", Path: []string{"Name"}}, + }, + }, + } + + if !statementReferencesVar(body[1], "CreatedRecord") { + t.Fatal("DECLARE initial values must be visible to custom handler skip-var routing") + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} + oc := fb.buildFlowGraph(body, nil) + + var callID, declareID model.ID + endIDs := map[model.ID]bool{} + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ActionActivity: + switch action := o.Action.(type) { + case *microflows.MicroflowCallAction: + if action.ResultVariableName == "CreatedRecord" { + callID = o.ID + } + case *microflows.CreateVariableAction: + if action.VariableName == "SuccessMessage" { + declareID = o.ID + } + } + case *microflows.EndEvent: + endIDs[o.ID] = true + } + } + if callID == "" || declareID == "" || len(endIDs) == 0 { + t.Fatalf("expected call, output-dependent declare, and end event; got call=%q declare=%q ends=%v", callID, declareID, endIDs) + } + + var errorFlowTerminates bool + for _, flow := range oc.Flows { + if !flow.IsErrorHandler || flow.OriginID != callID { + continue + } + if flowPathExists(oc.Flows, flow.DestinationID, declareID) { + t.Fatal("custom handler must not rejoin before a DECLARE that reads the missing output") + } + for endID := range endIDs { + if flow.DestinationID == endID || flowPathExists(oc.Flows, flow.DestinationID, endID) { + errorFlowTerminates = true + } + } + } + if !errorFlowTerminates { + t.Fatal("custom handler should terminate at an EndEvent before the output-dependent declare") + } +} + func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) { body := []ast.MicroflowStatement{ &ast.CallMicroflowStmt{ From ec3c9d4e20e02238a2c0139db0fd1bcbf677e624 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Wed, 29 Apr 2026 08:15:52 +0200 Subject: [PATCH 04/11] fix: avoid custom-handler var-ref helper collision Symptom: merging the custom error-handler routing PR with the variable-alias PR would redeclare statementVarRefs and statementsVarRefs in package executor. Root cause: both PRs added local helper names for different pre-passes while sharing the same package namespace. Fix: rename the custom error-handler helper pair to handler-specific names and add review-requested comments documenting the pending-handler state invariant, the bounded rejoin scan, and the top-entry anchor used for error-handler flows. Tests: make build; focused error-handler executor tests; make test. --- mdl/executor/cmd_microflows_builder.go | 15 ++++++++----- mdl/executor/cmd_microflows_builder_flows.go | 23 ++++++++++++-------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 94dbf221..7716cd11 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -52,11 +52,16 @@ type flowBuilder struct { // be overridden by the user. Cleared after each flow is created. previousStmtAnchor *ast.FlowAnchors // Cached flow lists to avoid repeated backend calls during lookups. - microflowsCache []*microflows.Microflow - microflowsCacheLoaded bool - nanoflowsCache []*microflows.Nanoflow - nanoflowsCacheLoaded bool - manualLoopBackTarget model.ID + microflowsCache []*microflows.Microflow + microflowsCacheLoaded bool + nanoflowsCache []*microflows.Nanoflow + nanoflowsCacheLoaded bool + manualLoopBackTarget model.ID + // Pending custom error-handler routing uses two representations: the + // currently active handler lives in the flat fields below, while handlers + // postponed across branch boundaries are queued in pendingErrorHandlers. + // Mutate this state through the helper methods in builder_flows.go so the + // active/queued invariant stays synchronized. emptyErrorHandlerFrom model.ID errorHandlerTailFrom model.ID errorHandlerSource model.ID diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 38ed2277..ed87c602 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -265,6 +265,9 @@ func (fb *flowBuilder) addErrorHandlerRejoinFlowForState(state pendingErrorHandl } func (fb *flowBuilder) findExistingRejoinMerge(originID, destinationID model.ID) model.ID { + // Error-handler rejoins are rare and microflows are small enough that an + // O(objects*flows) scan keeps the write path simpler than maintaining an + // incremental merge index. for _, flow := range fb.flows { if flow.OriginID != originID || flow.IsErrorHandler { continue @@ -296,7 +299,7 @@ func statementReferencesVar(stmt ast.MicroflowStatement, varName string) bool { if stmt == nil || varName == "" { return false } - for _, ref := range statementVarRefs(stmt) { + for _, ref := range errorHandlerStatementVarRefs(stmt) { if ref == varName { return true } @@ -316,7 +319,7 @@ func statementsReferenceVar(stmts []ast.MicroflowStatement, varName string) bool return false } -func statementVarRefs(stmt ast.MicroflowStatement) []string { +func errorHandlerStatementVarRefs(stmt ast.MicroflowStatement) []string { var refs []string switch s := stmt.(type) { case *ast.DeclareStmt: @@ -331,14 +334,14 @@ func statementVarRefs(stmt ast.MicroflowStatement) []string { } case *ast.IfStmt: refs = append(refs, exprVarRefs(s.Condition)...) - refs = append(refs, statementsVarRefs(s.ThenBody)...) - refs = append(refs, statementsVarRefs(s.ElseBody)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.ThenBody)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.ElseBody)...) case *ast.WhileStmt: refs = append(refs, exprVarRefs(s.Condition)...) - refs = append(refs, statementsVarRefs(s.Body)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.Body)...) case *ast.LoopStmt: refs = append(refs, s.ListVariable) - refs = append(refs, statementsVarRefs(s.Body)...) + refs = append(refs, errorHandlerStatementsVarRefs(s.Body)...) case *ast.MfSetStmt: refs = append(refs, extractVarName(s.Target)) refs = append(refs, exprVarRefs(s.Value)...) @@ -394,16 +397,18 @@ func statementVarRefs(stmt ast.MicroflowStatement) []string { return refs } -func statementsVarRefs(stmts []ast.MicroflowStatement) []string { +func errorHandlerStatementsVarRefs(stmts []ast.MicroflowStatement) []string { var refs []string for _, stmt := range stmts { - refs = append(refs, statementVarRefs(stmt)...) + refs = append(refs, errorHandlerStatementVarRefs(stmt)...) } return refs } // newErrorHandlerFlow creates a SequenceFlow with IsErrorHandler=true, -// connecting from the bottom of the source activity to the left of the error handler. +// connecting from the bottom of the source activity to the top of the handler. +// Studio Pro lays custom error handlers below their source, so the destination +// anchor enters from above rather than from the normal left-side continuation. func newErrorHandlerFlow(originID, destinationID model.ID) *microflows.SequenceFlow { return µflows.SequenceFlow{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, From c556b7fc8a39144435514615d882ae9b18476ec0 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Wed, 29 Apr 2026 10:31:37 +0200 Subject: [PATCH 05/11] fix: route branch-local output handlers to safe continuations Symptom: a custom error handler inside a returning IF branch could be forced to terminate when the branch success tail declared values from the failed action output. The resulting MDL inserted a return into the handler, and the generated MPR no longer matched the original control flow. Root cause: branch merge detection only treated empty custom handlers as needing a merge, and output skip-var routing stopped at the first output-dependent statement in void microflows. Derived variables declared from the failed output and SHOW MESSAGE template arguments were also invisible to the skip-var scan. Fix: treat non-terminal custom handlers as branch merge inputs, route pending handlers from returning branches to that merge, carry skip-var state across DECLARE-derived variables, and include SHOW MESSAGE expressions in handler reference analysis. Tests: added a synthetic branch-level regression covering a custom handler that skips an output-derived success tail and rejoins at a shared error continuation. Ran make build, make test, and make lint-go. --- .../cmd_microflows_builder_control.go | 73 +++++++++- mdl/executor/cmd_microflows_builder_flows.go | 48 ++++++- .../cmd_microflows_builder_terminal_test.go | 125 +++++++++++++++++- 3 files changed, 238 insertions(+), 8 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index e47ca4f7..3a3990bf 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -34,6 +34,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { hasElseBody := len(s.ElseBody) > 0 elseReturns := hasElseBody && lastStmtIsReturn(s.ElseBody) bothReturn := hasElseBody && thenReturns && elseReturns + thenNeedsErrorMerge := thenReturns && bodyHasContinuingCustomErrorHandler(s.ThenBody) + elseNeedsErrorMerge := elseReturns && bodyHasContinuingCustomErrorHandler(s.ElseBody) // Save/restore endsWithReturn around branch processing to avoid // a branch's RETURN affecting the parent flow state prematurely @@ -86,7 +88,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { needMerge := false if !bothReturn { if hasElseBody { - needMerge = !thenReturns && !elseReturns // both branches continue → 2 inputs + needMerge = (!thenReturns && !elseReturns) || thenNeedsErrorMerge || elseNeedsErrorMerge } else { needMerge = !thenReturns // THEN continues + FALSE path → 2 inputs } @@ -160,6 +162,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, trueBranchAnchor, trueBranchAnchor) fb.flows = append(fb.flows, flow) } + } else if thenReturns && needMerge { + fb.addPendingErrorHandlerFlowTo(mergeID) } // Process ELSE body (below the THEN path) @@ -206,6 +210,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { applyUserAnchors(flow, prevElseAnchor, nil) fb.flows = append(fb.flows, flow) } + } else if elseReturns && needMerge { + fb.addPendingErrorHandlerFlowTo(mergeID) } } else { // IF WITHOUT ELSE: FALSE path horizontal (happy path), TRUE path below @@ -322,6 +328,71 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { return splitID } +func bodyHasContinuingCustomErrorHandler(stmts []ast.MicroflowStatement) bool { + for _, stmt := range stmts { + switch s := stmt.(type) { + case *ast.CallMicroflowStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.CallJavaActionStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.RestCallStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.ImportFromMappingStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.CreateObjectStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.MfCommitStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.DeleteObjectStmt: + if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) { + return true + } + case *ast.IfStmt: + if bodyHasContinuingCustomErrorHandler(s.ThenBody) || bodyHasContinuingCustomErrorHandler(s.ElseBody) { + return true + } + case *ast.LoopStmt: + if bodyHasContinuingCustomErrorHandler(s.Body) { + return true + } + case *ast.WhileStmt: + if bodyHasContinuingCustomErrorHandler(s.Body) { + return true + } + } + } + return false +} + +func isContinuingCustomErrorHandler(eh *ast.ErrorHandlingClause) bool { + if eh == nil { + return false + } + if eh.Type != ast.ErrorHandlingCustom && eh.Type != ast.ErrorHandlingCustomWithoutRollback { + return false + } + return len(eh.Body) == 0 || !lastStmtIsReturn(eh.Body) +} + +func errorBody(eh *ast.ErrorHandlingClause) []ast.MicroflowStatement { + if eh == nil { + return nil + } + return eh.Body +} + // addLoopStatement creates a LOOP statement using LoopedActivity. // Layout: Auto-sizes the loop box to fit content with padding func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index ed87c602..ee9aff60 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -127,6 +127,27 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowForStatement(originID, destinat }) } +func (fb *flowBuilder) addPendingErrorHandlerFlowTo(destinationID model.ID) { + if destinationID == "" { + return + } + fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState { + if state.emptyFrom != "" { + fb.addEmptyErrorHandlerRejoinFlowFrom(state.emptyFrom, state.emptyFrom, destinationID) + state.emptyFrom = "" + } + if state.source != "" && state.tailFrom != "" { + fb.addErrorHandlerRejoinFlowForState(state, state.source, destinationID) + state.source = "" + state.tailFrom = "" + state.skipVar = "" + state.tailIsSource = false + state.returnValue = "" + } + return state + }) +} + func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHandlerState, originID, destinationID model.ID, stmt ast.MicroflowStatement, futureReferencesSkipVar bool) pendingErrorHandlerState { if destinationID == "" { return state @@ -147,13 +168,10 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHand if state.skipVar != "" { if statementReferencesVar(stmt, state.skipVar) { if !fb.hasReturnValue { - endID := fb.addTerminalEndEventForPendingHandler(fb.returnType, "") - if state.tailIsSource { - fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, endID)) - } else { - fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, endID)) + if derivedVar := outputDerivedVariable(stmt, state.skipVar); derivedVar != "" { + state.skipVar = derivedVar } - return pendingErrorHandlerState{} + return state } return state } @@ -332,6 +350,11 @@ func errorHandlerStatementVarRefs(stmt ast.MicroflowStatement) []string { for _, param := range s.Template { refs = append(refs, exprVarRefs(param.Value)...) } + case *ast.ShowMessageStmt: + refs = append(refs, exprVarRefs(s.Message)...) + for _, arg := range s.TemplateArgs { + refs = append(refs, exprVarRefs(arg)...) + } case *ast.IfStmt: refs = append(refs, exprVarRefs(s.Condition)...) refs = append(refs, errorHandlerStatementsVarRefs(s.ThenBody)...) @@ -397,6 +420,19 @@ func errorHandlerStatementVarRefs(stmt ast.MicroflowStatement) []string { return refs } +func outputDerivedVariable(stmt ast.MicroflowStatement, sourceVar string) string { + declare, ok := stmt.(*ast.DeclareStmt) + if !ok || declare.Variable == "" { + return "" + } + for _, ref := range exprVarRefs(declare.InitialValue) { + if ref == sourceVar { + return declare.Variable + } + } + return "" +} + func errorHandlerStatementsVarRefs(stmts []ast.MicroflowStatement) []string { var refs []string for _, stmt := range stmts { diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index af103ca3..64193fa1 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -3,6 +3,7 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" @@ -429,7 +430,7 @@ func TestBuildFlowGraph_OutputHandlerTerminatesBeforeDeclareReferencingOutput(t body := []ast.MicroflowStatement{ &ast.CallMicroflowStmt{ OutputVariable: "CreatedRecord", - MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "CreateRecord"}, + MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "CreateRecord"}, ErrorHandling: &ast.ErrorHandlingClause{ Type: ast.ErrorHandlingCustomWithoutRollback, Body: []ast.MicroflowStatement{ @@ -497,6 +498,128 @@ func TestBuildFlowGraph_OutputHandlerTerminatesBeforeDeclareReferencingOutput(t } } +func TestBuildFlowGraph_OutputHandlerInReturningBranchSkipsDerivedSuccessTail(t *testing.T) { + successMessage := &ast.VariableExpr{Name: "SuccessMessage"} + body := []ast.MicroflowStatement{ + &ast.DeclareStmt{ + Variable: "ErrorMessage", + Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.LiteralExpr{Kind: ast.LiteralString, Value: ""}, + }, + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "CanCreate"}, + ThenBody: []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + OutputVariable: "CreatedRecord", + MicroflowName: ast.QualifiedName{Module: "SampleService", Name: "CreateRecord"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustomWithoutRollback, + Body: []ast.MicroflowStatement{ + &ast.MfSetStmt{ + Target: "ErrorMessage", + Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "create failed"}, + }, + }, + }, + }, + &ast.DeclareStmt{ + Variable: "SuccessMessage", + Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.BinaryExpr{ + Left: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "Created "}, + Operator: "+", + Right: &ast.AttributePathExpr{Variable: "CreatedRecord", Path: []string{"Name"}}, + }, + }, + &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Template: []ast.TemplateParam{ + {Index: 1, Value: successMessage}, + }, + }, + &ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Type: "Information", + TemplateArgs: []ast.Expression{successMessage}, + }, + &ast.ClosePageStmt{}, + &ast.ReturnStmt{}, + }, + ElseBody: []ast.MicroflowStatement{ + &ast.MfSetStmt{ + Target: "ErrorMessage", + Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "not found"}, + }, + }, + }, + &ast.LogStmt{ + Level: ast.LogError, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Template: []ast.TemplateParam{ + {Index: 1, Value: &ast.VariableExpr{Name: "ErrorMessage"}}, + }, + }, + &ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"}, + Type: "Error", + TemplateArgs: []ast.Expression{&ast.VariableExpr{Name: "ErrorMessage"}}, + }, + &ast.ReturnStmt{}, + } + + if !statementReferencesVar(body[1].(*ast.IfStmt).ThenBody[3], "SuccessMessage") { + t.Fatal("SHOW MESSAGE arguments must be visible to custom handler skip-var routing") + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"CanCreate": "Boolean"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, nil) + + var callID, handlerSetID, successDeclareID, errorLogID model.ID + for _, obj := range oc.Objects { + activity, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + switch action := activity.Action.(type) { + case *microflows.MicroflowCallAction: + if action.ResultVariableName == "CreatedRecord" { + callID = activity.ID + } + case *microflows.ChangeVariableAction: + if action.VariableName == "ErrorMessage" && strings.Contains(action.Value, "create failed") { + handlerSetID = activity.ID + } + case *microflows.CreateVariableAction: + if action.VariableName == "SuccessMessage" { + successDeclareID = activity.ID + } + case *microflows.LogMessageAction: + if action.LogLevel == "Error" { + errorLogID = activity.ID + } + } + } + if callID == "" || handlerSetID == "" || successDeclareID == "" || errorLogID == "" { + t.Fatalf("expected source call, handler set, success declare, and error log; got call=%q handler=%q declare=%q errorLog=%q", callID, handlerSetID, successDeclareID, errorLogID) + } + if !flowPathExists(oc.Flows, callID, handlerSetID) { + t.Fatal("source call must connect to the custom handler body") + } + if flowPathExists(oc.Flows, handlerSetID, successDeclareID) { + t.Fatal("custom handler must skip the output-derived success tail") + } + if !flowPathExists(oc.Flows, handlerSetID, errorLogID) { + t.Fatal("custom handler in a returning branch must rejoin at the shared safe continuation") + } +} + func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) { body := []ast.MicroflowStatement{ &ast.CallMicroflowStmt{ From 3bf45d45f2e4027bcd24c51793ac4c35b817dd26 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 10:00:17 +0200 Subject: [PATCH 06/11] fix: route java and nanoflow custom handlers through shared helper Symptom: after rebasing the custom error-handler routing PR, empty custom handlers on Java and nanoflow calls were still handled by the old body-only path, so output-dependent tails had no safe error-handler termination. Root cause: the rebase kept legacy per-action handler code for those call actions instead of the shared finishCustomErrorHandler helper used by the rest of the PR. Fix: route both actions through finishCustomErrorHandler so empty handlers, output skip variables, and non-empty handlers use the same pending-handler state machine. Tests: make build; make test; make lint-go. --- mdl/executor/cmd_microflows_builder_calls.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 92474303..d6604fcb 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -213,12 +213,7 @@ func (fb *flowBuilder) addCallNanoflowAction(s *ast.CallNanoflowStmt) model.ID { fb.registerResultVariableType(s.OutputVariable, fb.lookupNanoflowReturnType(nfQN)) } - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } @@ -320,12 +315,7 @@ func (fb *flowBuilder) addCallJavaActionAction(s *ast.CallJavaActionStmt) model. fb.objects = append(fb.objects, activity) fb.posX += fb.spacing - // Build custom error handler flow if present - if s.ErrorHandling != nil && len(s.ErrorHandling.Body) > 0 { - errorY := fb.posY + VerticalSpacing - mergeID := fb.addErrorHandlerFlow(activity.ID, activityX, s.ErrorHandling.Body) - fb.handleErrorHandlerMerge(mergeID, activity.ID, errorY) - } + fb.finishCustomErrorHandler(activity.ID, activityX, s.ErrorHandling, s.OutputVariable) return activity.ID } From 97ab86f00b0edee30338db53f145c320c8935e23 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 15:10:40 +0200 Subject: [PATCH 07/11] refactor: derive custom handler return-state from return type Symptom: the custom error-handler builder stored both returnType and a redundant hasReturnValue boolean, leaving two sources of truth for whether the microflow returns a value. Root cause: hasReturnValue was assigned from returnType during graph construction and copied into nested error-handler builders even though the same answer can be derived directly from returnType. Fix: remove the boolean field, add a small hasDeclaredReturnValue helper, and use it at the custom-handler routing decision point. Tests: ran make build, targeted executor custom-error-handler tests, make test, and make lint-go. --- mdl/executor/cmd_microflows_builder.go | 5 +++- mdl/executor/cmd_microflows_builder_flows.go | 25 ++++++++++---------- mdl/executor/cmd_microflows_builder_graph.go | 1 - 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 7716cd11..015291a4 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -25,7 +25,6 @@ type flowBuilder struct { spacing int returnValue string // Return value expression for RETURN statement (used by buildFlowGraph final EndEvent) returnType *ast.MicroflowReturnType - hasReturnValue bool // True when the microflow declares a non-void return type endsWithReturn bool // True if the flow already ends with EndEvent(s) from RETURN statements lastReturnEndID model.ID // Last explicit RETURN EndEvent, used as a fallback error-handler target varTypes map[string]string // Variable name -> entity qualified name (for CHANGE statements) @@ -86,6 +85,10 @@ func (fb *flowBuilder) GetErrors() []string { return fb.errors } +func (fb *flowBuilder) hasDeclaredReturnValue() bool { + return fb.returnType != nil && fb.returnType.Type.Kind != ast.TypeVoid +} + // errorExampleDeclareVariable returns an example for declaring a variable. func errorExampleDeclareVariable(varName string) string { // Remove $ prefix if present for cleaner display diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index ee9aff60..502ab88e 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -167,7 +167,7 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHand } if state.skipVar != "" { if statementReferencesVar(stmt, state.skipVar) { - if !fb.hasReturnValue { + if !fb.hasDeclaredReturnValue() { if derivedVar := outputDerivedVariable(stmt, state.skipVar); derivedVar != "" { state.skipVar = derivedVar } @@ -471,18 +471,17 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // Build error handler activities errBuilder := &flowBuilder{ - posX: errorX, - posY: errorY, - baseY: errorY, - spacing: HorizontalSpacing, - returnType: fb.returnType, - hasReturnValue: fb.hasReturnValue, - varTypes: fb.varTypes, - declaredVars: fb.declaredVars, - measurer: fb.measurer, - backend: fb.backend, - hierarchy: fb.hierarchy, - restServices: fb.restServices, + posX: errorX, + posY: errorY, + baseY: errorY, + spacing: HorizontalSpacing, + returnType: fb.returnType, + varTypes: fb.varTypes, + declaredVars: fb.declaredVars, + measurer: fb.measurer, + backend: fb.backend, + hierarchy: fb.hierarchy, + restServices: fb.restServices, } var lastErrID model.ID diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 435f3414..b661fe1d 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -31,7 +31,6 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a if returns != nil && returns.Variable != "" { fb.returnValue = "$" + returns.Variable } - fb.hasReturnValue = returns != nil && returns.Type.Kind != ast.TypeVoid // Set baseY for branch restoration (this is the center line) fb.baseY = fb.posY From 3156c1d2100f6ea2962ae8106df6b70aeb8a8f02 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 17:04:31 +0200 Subject: [PATCH 08/11] fix: emit structured conditionals in custom error handlers Symptom: describe emitted IF statements inside custom error-handler blocks without the matching END IF, so exec rejected valid roundtripped handlers. Root cause: error-handler traversal linearized every reachable activity until the first merge and formatted nested splits as plain statements, losing block structure. Fix: traverse error-handler branches structurally, stop at the handler rejoin merge, and emit IF/ELSE/END IF around nested exclusive splits. Tests: added a synthetic error-handler graph with a nested conditional and ran make test. --- mdl/executor/cmd_microflows_show_helpers.go | 93 ++++++++++++++++---- mdl/executor/cmd_microflows_traverse_test.go | 37 ++++++++ 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 3286eae3..d6a1e587 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1237,42 +1237,103 @@ func collectErrorHandlerStatements( ) []string { var statements []string visited := make(map[model.ID]bool) + stopID := firstReachableErrorHandlerMerge(startID, activityMap, flowsByOrigin) + splitMergeMap := findErrorHandlerSplitMergePoints(ctx, activityMap, flowsByOrigin) - var traverse func(id model.ID) - traverse = func(id model.ID) { - if id == "" || visited[id] { + var traverse func(id model.ID, boundary model.ID, indent int) + traverse = func(id model.ID, boundary model.ID, indent int) { + if id == "" || id == boundary || visited[id] { return } - obj := activityMap[id] if obj == nil { return } - - // Stop at merge points (rejoin with main flow) or end events if _, isMerge := obj.(*microflows.ExclusiveMerge); isMerge { return } - visited[id] = true - stmt := formatActivity(ctx, obj, entityNames, microflowNames) - if stmt != "" { - statements = append(statements, stmt) + indentStr := strings.Repeat(" ", indent) + if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { + stmt := formatActivity(ctx, obj, entityNames, microflowNames) + if stmt != "" { + statements = append(statements, indentStr+stmt) + } + nestedMergeID := splitMergeMap[id] + trueFlow, falseFlow := findBranchFlows(flowsByOrigin[id]) + if trueFlow != nil { + traverse(trueFlow.DestinationID, nestedMergeID, indent+1) + } + if falseFlow != nil && falseFlow.DestinationID != nestedMergeID { + statements = append(statements, indentStr+"else") + traverse(falseFlow.DestinationID, nestedMergeID, indent+1) + } + if stmt != "" { + statements = append(statements, indentStr+"end if;") + } + if nestedMergeID != "" && nestedMergeID != boundary { + visited[nestedMergeID] = true + for _, flow := range findNormalFlows(flowsByOrigin[nestedMergeID]) { + traverse(flow.DestinationID, boundary, indent) + } + } + return } - // Follow normal (non-error) flows - flows := flowsByOrigin[id] - normalFlows := findNormalFlows(flows) - for _, flow := range normalFlows { - traverse(flow.DestinationID) + if stmt := formatActivity(ctx, obj, entityNames, microflowNames); stmt != "" { + statements = append(statements, indentStr+stmt) + } + for _, flow := range findNormalFlows(flowsByOrigin[id]) { + traverse(flow.DestinationID, boundary, indent) } } - traverse(startID) + traverse(startID, stopID, 0) return statements } +func findErrorHandlerSplitMergePoints( + ctx *ExecContext, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, +) map[model.ID]model.ID { + result := make(map[model.ID]model.ID) + for id, obj := range activityMap { + if _, isSplit := obj.(*microflows.ExclusiveSplit); !isSplit { + continue + } + if mergeID := findMergeForSplit(ctx, id, flowsByOrigin, activityMap); mergeID != "" { + result[id] = mergeID + } + } + return result +} + +func firstReachableErrorHandlerMerge( + startID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, +) model.ID { + visited := make(map[model.ID]bool) + queue := []model.ID{startID} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if id == "" || visited[id] { + continue + } + visited[id] = true + if _, isMerge := activityMap[id].(*microflows.ExclusiveMerge); isMerge { + return id + } + for _, flow := range findNormalFlows(flowsByOrigin[id]) { + queue = append(queue, flow.DestinationID) + } + } + return "" +} + // loopEndKeyword returns "END WHILE" for WHILE loops and "END LOOP" for FOR-EACH loops. func loopEndKeyword(loop *microflows.LoopedActivity) string { if _, isWhile := loop.LoopSource.(*microflows.WhileLoopCondition); isWhile { diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index ba60da11..bca9b355 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -244,6 +244,43 @@ func TestCollectErrorHandlerStatements_StopsAtMerge(t *testing.T) { } } +func TestCollectErrorHandlerStatements_StructuredIfEmitsEndIf(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$latestHttpResponse != empty"}, + }, + mkID("return_error"): µflows.EndEvent{ + BaseMicroflowObject: mkObj("return_error"), + ReturnValue: "latestHttpResponse", + }, + mkID("merge"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("merge")}, + mkID("after"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("after")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'Synthetic'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "after"}}}, + }, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("split"): { + mkBranchFlow("split", "return_error", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("split", "merge", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("merge"): {mkFlow("merge", "after")}, + } + + stmts := e.collectErrorHandlerStatements(mkID("split"), activityMap, flowsByOrigin, nil, nil) + got := strings.Join(stmts, "\n") + + assertContains(t, got, "if $latestHttpResponse != empty then") + assertContains(t, got, "return $latestHttpResponse;") + assertContains(t, got, "end if;") + if strings.Contains(got, "after") { + t.Fatalf("error handler traversal crossed the rejoin merge: %s", got) + } +} + func TestCollectErrorHandlerStatements_EmptyID(t *testing.T) { e := newTestExecutor() stmts := e.collectErrorHandlerStatements("", nil, nil, nil, nil) From 2d19dc3c4f2c07d265745211aa3581cbaea0c408 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 18:22:38 +0200 Subject: [PATCH 09/11] fix: preserve empty else branches in custom handlers Symptom: describing a custom error handler could drop an empty else branch from an inner decision, and exec then wrote a decision with no valid false case for Studio Pro mx check. Root cause: custom handler traversal skipped false flows that rejoined at the handler-local merge, matching normal describe cleanup but losing required branch metadata inside the handler graph. Fix: emit the else marker whenever the false flow exists, while still avoiding traversal through an empty false branch. Tests: extend the structured custom-handler traversal regression to assert the empty else is preserved. --- mdl/executor/cmd_microflows_show_helpers.go | 6 ++++-- mdl/executor/cmd_microflows_traverse_test.go | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 5f808cae..3f02fed1 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1408,9 +1408,11 @@ func collectErrorHandlerStatements( if trueFlow != nil { traverse(trueFlow.DestinationID, nestedMergeID, indent+1) } - if falseFlow != nil && falseFlow.DestinationID != nestedMergeID { + if falseFlow != nil { statements = append(statements, indentStr+"else") - traverse(falseFlow.DestinationID, nestedMergeID, indent+1) + if falseFlow.DestinationID != nestedMergeID { + traverse(falseFlow.DestinationID, nestedMergeID, indent+1) + } } if stmt != "" { statements = append(statements, indentStr+"end if;") diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index 00bf2997..f6bd5cfb 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -746,6 +746,7 @@ func TestCollectErrorHandlerStatements_StructuredIfEmitsEndIf(t *testing.T) { assertContains(t, got, "if $latestHttpResponse != empty then") assertContains(t, got, "return $latestHttpResponse;") + assertContains(t, got, "else") assertContains(t, got, "end if;") if strings.Contains(got, "after") { t.Fatalf("error handler traversal crossed the rejoin merge: %s", got) From c8ed989d297305190aba17cb8f1b7bae9ba6664c Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 19:20:44 +0200 Subject: [PATCH 10/11] fix: preserve explicit empty else branches through exec Symptom: MDL with an explicit empty else branch could execute into a custom error-handler split whose false path had no condition, causing mx check to report a missing or invalid split condition after roundtrip. Root cause: the visitor represented IF statements only by branch bodies. An explicit `else` with no statements was indistinguishable from an omitted else, so the builder omitted the false continuation that Studio Pro needs for that graph shape. Fix: add IfStmt.HasElse, set it when the source contains ELSE, and have the builder and MDL re-emitter preserve that explicit empty branch. Tests: added visitor coverage for parsing empty ELSE and executor coverage that the false path reaches the following return; ran make build && make test. --- mdl/ast/ast_microflow.go | 1 + mdl/executor/cmd_diff_mdl.go | 2 +- .../cmd_microflows_builder_control.go | 2 +- .../cmd_microflows_builder_terminal_test.go | 71 +++++++++++++++++++ mdl/visitor/visitor_microflow_statements.go | 3 + mdl/visitor/visitor_test.go | 35 +++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index b611216d..112fdc51 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -249,6 +249,7 @@ type IfStmt struct { Condition Expression // IF condition ThenBody []MicroflowStatement // THEN branch ElseBody []MicroflowStatement // ELSE branch (optional) + HasElse bool // true when the source contained ELSE, even if the body is empty Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index 57f0f7d8..5ad30d67 100644 --- a/mdl/executor/cmd_diff_mdl.go +++ b/mdl/executor/cmd_diff_mdl.go @@ -423,7 +423,7 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde for _, thenStmt := range s.ThenBody { lines = append(lines, microflowStatementToMDL(ctx, thenStmt, indent+1)...) } - if len(s.ElseBody) > 0 { + if s.HasElse || len(s.ElseBody) > 0 { lines = append(lines, indentStr+"else") for _, elseStmt := range s.ElseBody { lines = append(lines, microflowStatementToMDL(ctx, elseStmt, indent+1)...) diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 3a3990bf..cd346f46 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -31,7 +31,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID { // Check if branches end with RETURN (creating their own EndEvents) thenReturns := lastStmtIsReturn(s.ThenBody) - hasElseBody := len(s.ElseBody) > 0 + hasElseBody := s.HasElse || len(s.ElseBody) > 0 elseReturns := hasElseBody && lastStmtIsReturn(s.ElseBody) bothReturn := hasElseBody && thenReturns && elseReturns thenNeedsErrorMerge := thenReturns && bodyHasContinuingCustomErrorHandler(s.ThenBody) diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index 64193fa1..a7b64925 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -663,6 +663,77 @@ func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) { t.Fatal("empty no-output handler should rejoin at the next action") } +func TestBuildFlowGraph_ExplicitEmptyElseProvidesFalseContinuation(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "HasResponse"}, + HasElse: true, + ThenBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.VariableExpr{Name: "ErrorResponse"}}, + }, + }, + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralNull}}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"HasResponse": "Boolean", "ErrorResponse": "Synthetic.Error"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeEntity, EntityRef: &ast.QualifiedName{Module: "Synthetic", Name: "Error"}}}) + + var splitID, emptyEndID model.ID + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ExclusiveSplit: + splitID = o.ID + case *microflows.EndEvent: + if o.ReturnValue == "empty" { + emptyEndID = o.ID + } + } + } + if splitID == "" || emptyEndID == "" { + t.Fatalf("expected split and empty return end event, got split=%q emptyEnd=%q", splitID, emptyEndID) + } + + for _, flow := range oc.Flows { + if flow.OriginID != splitID || flowCaseString(flow.CaseValue) != "false" { + continue + } + if flowPathExists(oc.Flows, flow.DestinationID, emptyEndID) { + return + } + } + t.Fatal("expected explicit empty else to produce a false-path continuation") +} + +func flowCaseString(caseValue microflows.CaseValue) string { + switch c := caseValue.(type) { + case microflows.EnumerationCase: + return c.Value + case *microflows.EnumerationCase: + if c != nil { + return c.Value + } + case microflows.BooleanCase: + if c.Value { + return "true" + } + return "false" + case *microflows.BooleanCase: + if c != nil && c.Value { + return "true" + } + if c != nil { + return "false" + } + } + return "" +} + func flowPathExists(flows []*microflows.SequenceFlow, startID, targetID model.ID) bool { if startID == "" || targetID == "" { return false diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index ca788eea..8ef2e452 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1163,7 +1163,10 @@ func buildIfStatement(ctx parser.IIfStatementContext) *ast.IfStmt { } // Last body is ELSE if there's no ELSIF or if there are more bodies than expressions if len(bodies) > len(exprs) { + stmt.HasElse = true stmt.ElseBody = buildMicroflowBody(bodies[len(bodies)-1]) + } else if ifCtx.ELSE() != nil { + stmt.HasElse = true } return stmt diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index 4012678c..781c21bd 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -542,6 +542,41 @@ END;` t.Log("IF/THEN/ELSE parsed correctly - actions in correct branches") } +func TestIfThenEmptyElsePreservesElsePresence(t *testing.T) { + input := `create microflow Test.EmptyElse() +returns String +begin + if $latestHttpResponse != empty then + return 'error'; + else + end if; + return empty; +end;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt) + if len(stmt.Body) == 0 { + t.Fatal("expected microflow body") + } + ifStmt, ok := stmt.Body[0].(*ast.IfStmt) + if !ok { + t.Fatalf("expected first statement to be IfStmt, got %T", stmt.Body[0]) + } + if !ifStmt.HasElse { + t.Fatal("expected explicit empty else to be preserved") + } + if len(ifStmt.ElseBody) != 0 { + t.Fatalf("empty else body length = %d, want 0", len(ifStmt.ElseBody)) + } +} + // TestValidationFeedbackInsideIf verifies VALIDATION FEEDBACK works inside IF blocks. // Bug Report: "VALIDATION FEEDBACK Not Recognized" func TestValidationFeedbackInsideIf(t *testing.T) { From 129e81de0d2549acebb9f960a0e69578910ce289 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 19:37:37 +0200 Subject: [PATCH 11/11] fix: keep deferred error-handler branch cases Symptom: custom error handlers containing `if X then return ... else end if` wrote a false branch rejoin flow without a case value. Studio Pro reported the outgoing sequence flow from the decision as missing its false condition. Root cause: addErrorHandlerFlow kept only the deferred tail node ID from nested merge-less splits. It discarded nextFlowCase and nextFlowAnchor before the pending error-handler rejoin was materialized. Fix: carry the deferred tail case and anchor through pending error-handler state, and apply them when the handler rejoins the normal continuation. Tests: added synthetic coverage for a custom error handler with an explicit empty else rejoining through CaseValue=false; ran make build && make test. --- mdl/executor/cmd_microflows_builder.go | 2 + mdl/executor/cmd_microflows_builder_flows.go | 65 +++++++++++++++---- .../cmd_microflows_builder_terminal_test.go | 60 +++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 06a98b08..1c3b99f2 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -65,6 +65,8 @@ type flowBuilder struct { errorHandlerTailFrom model.ID errorHandlerSource model.ID errorHandlerSkipVar string + errorHandlerTailCase string + errorHandlerTailAnchor *ast.FlowAnchors errorHandlerTailIsSource bool errorHandlerReturnValue string pendingErrorHandlers []pendingErrorHandlerState diff --git a/mdl/executor/cmd_microflows_builder_flows.go b/mdl/executor/cmd_microflows_builder_flows.go index 502ab88e..a2e37c55 100644 --- a/mdl/executor/cmd_microflows_builder_flows.go +++ b/mdl/executor/cmd_microflows_builder_flows.go @@ -69,6 +69,8 @@ type pendingErrorHandlerState struct { tailFrom model.ID source model.ID skipVar string + tailCase string + tailAnchor *ast.FlowAnchors tailIsSource bool returnValue string } @@ -83,6 +85,8 @@ func (fb *flowBuilder) activePendingErrorHandler() pendingErrorHandlerState { tailFrom: fb.errorHandlerTailFrom, source: fb.errorHandlerSource, skipVar: fb.errorHandlerSkipVar, + tailCase: fb.errorHandlerTailCase, + tailAnchor: fb.errorHandlerTailAnchor, tailIsSource: fb.errorHandlerTailIsSource, returnValue: fb.errorHandlerReturnValue, } @@ -93,6 +97,8 @@ func (fb *flowBuilder) setActivePendingErrorHandler(state pendingErrorHandlerSta fb.errorHandlerTailFrom = state.tailFrom fb.errorHandlerSource = state.source fb.errorHandlerSkipVar = state.skipVar + fb.errorHandlerTailCase = state.tailCase + fb.errorHandlerTailAnchor = state.tailAnchor fb.errorHandlerTailIsSource = state.tailIsSource fb.errorHandlerReturnValue = state.returnValue } @@ -141,6 +147,8 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowTo(destinationID model.ID) { state.source = "" state.tailFrom = "" state.skipVar = "" + state.tailCase = "" + state.tailAnchor = nil state.tailIsSource = false state.returnValue = "" } @@ -188,6 +196,12 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHand return state } +type errorHandlerTail struct { + id model.ID + caseValue string + flowAnchor *ast.FlowAnchors +} + func (fb *flowBuilder) addEmptyErrorHandlerRejoinFlowFrom(normalOriginID, errorOriginID, destinationID model.ID) { existingIdx := -1 for i := len(fb.flows) - 1; i >= 0; i-- { @@ -243,14 +257,18 @@ func (fb *flowBuilder) addErrorHandlerRejoinFlowForState(state pendingErrorHandl if state.tailIsSource { fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, mergeID)) } else { - fb.flows = append(fb.flows, newUpwardFlow(state.tailFrom, mergeID)) + flow := newUpwardFlow(state.tailFrom, mergeID) + applyDeferredFlowCase(flow, state.tailCase, state.tailAnchor) + fb.flows = append(fb.flows, flow) } return } if state.tailIsSource { fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, destinationID)) } else { - fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, destinationID)) + flow := newHorizontalFlow(state.tailFrom, destinationID) + applyDeferredFlowCase(flow, state.tailCase, state.tailAnchor) + fb.flows = append(fb.flows, flow) } return } @@ -274,7 +292,9 @@ func (fb *flowBuilder) addErrorHandlerRejoinFlowForState(state pendingErrorHandl if state.tailIsSource { fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, merge.ID)) } else { - fb.flows = append(fb.flows, newUpwardFlow(state.tailFrom, merge.ID)) + flow := newUpwardFlow(state.tailFrom, merge.ID) + applyDeferredFlowCase(flow, state.tailCase, state.tailAnchor) + fb.flows = append(fb.flows, flow) } mergeFlow := newHorizontalFlow(merge.ID, destinationID) @@ -282,6 +302,19 @@ func (fb *flowBuilder) addErrorHandlerRejoinFlowForState(state pendingErrorHandl fb.flows = append(fb.flows, mergeFlow) } +func applyDeferredFlowCase(flow *microflows.SequenceFlow, caseValue string, anchor *ast.FlowAnchors) { + if flow == nil { + return + } + if caseValue != "" { + flow.CaseValue = microflows.EnumerationCase{ + BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, + Value: caseValue, + } + } + applyUserAnchors(flow, anchor, anchor) +} + func (fb *flowBuilder) findExistingRejoinMerge(originID, destinationID model.ID) model.ID { // Error-handler rejoins are rare and microflows are small enough that an // O(objects*flows) scan keeps the write path simpler than maintaining an @@ -460,9 +493,9 @@ func newErrorHandlerFlow(originID, destinationID model.ID) *microflows.SequenceF // positions them below the source activity, and connects them with an error handler flow. // Returns the last activity ID if the error handler should merge back to the main flow. // Returns empty model.ID if the error handler terminates (via RAISE ERROR or RETURN). -func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX int, errorBody []ast.MicroflowStatement) model.ID { +func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX int, errorBody []ast.MicroflowStatement) errorHandlerTail { if len(errorBody) == 0 { - return "" + return errorHandlerTail{} } // Position error handler below the main flow @@ -485,6 +518,8 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in } var lastErrID model.ID + var lastErrCase string + var lastErrAnchor *ast.FlowAnchors for _, stmt := range errorBody { actID := errBuilder.addStatement(stmt) if actID != "" { @@ -497,9 +532,15 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in } if errBuilder.nextConnectionPoint != "" { lastErrID = errBuilder.nextConnectionPoint + lastErrCase = errBuilder.nextFlowCase + lastErrAnchor = errBuilder.nextFlowAnchor errBuilder.nextConnectionPoint = "" + errBuilder.nextFlowCase = "" + errBuilder.nextFlowAnchor = nil } else { lastErrID = actID + lastErrCase = "" + lastErrAnchor = nil } } } @@ -511,27 +552,29 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in // If the error handler ends with RAISE ERROR or RETURN, it terminates there. // Otherwise, return the last activity ID so caller can create a merge. if errBuilder.endsWithReturn { - return "" // Error handler terminates, no merge needed + return errorHandlerTail{} // Error handler terminates, no merge needed } - return lastErrID // Error handler should merge back to main flow + return errorHandlerTail{id: lastErrID, caseValue: lastErrCase, flowAnchor: lastErrAnchor} // Error handler should merge back to main flow } // handleErrorHandlerMerge creates an EndEvent for error handlers that want to merge back. // This is a fallback until full merge support is implemented. Caller should pass // the ID returned by addErrorHandlerFlow and the error handler Y position. func (fb *flowBuilder) handleErrorHandlerMerge(lastErrID model.ID, activityID model.ID, errorY int) { - fb.handleErrorHandlerMergeWithSkip(lastErrID, activityID, errorY, "") + fb.handleErrorHandlerMergeWithSkip(errorHandlerTail{id: lastErrID}, activityID, errorY, "") } -func (fb *flowBuilder) handleErrorHandlerMergeWithSkip(lastErrID model.ID, activityID model.ID, errorY int, skipVar string) { - if lastErrID == "" { +func (fb *flowBuilder) handleErrorHandlerMergeWithSkip(tail errorHandlerTail, activityID model.ID, errorY int, skipVar string) { + if tail.id == "" { return // No merge needed (error handler terminates with RETURN or RAISE ERROR) } _ = errorY fb.queueActivePendingErrorHandler() fb.errorHandlerSource = activityID - fb.errorHandlerTailFrom = lastErrID + fb.errorHandlerTailFrom = tail.id fb.errorHandlerSkipVar = skipVar + fb.errorHandlerTailCase = tail.caseValue + fb.errorHandlerTailAnchor = tail.flowAnchor fb.errorHandlerTailIsSource = false fb.errorHandlerReturnValue = fb.returnValue } diff --git a/mdl/executor/cmd_microflows_builder_terminal_test.go b/mdl/executor/cmd_microflows_builder_terminal_test.go index a7b64925..e84aa82b 100644 --- a/mdl/executor/cmd_microflows_builder_terminal_test.go +++ b/mdl/executor/cmd_microflows_builder_terminal_test.go @@ -663,6 +663,66 @@ func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) { t.Fatal("empty no-output handler should rejoin at the next action") } +func TestBuildFlowGraph_ErrorHandlerEmptyElseKeepsFalseCaseOnRejoin(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "Synthetic", Name: "PatchRemoteState"}, + ErrorHandling: &ast.ErrorHandlingClause{ + Type: ast.ErrorHandlingCustom, + Body: []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "HasResponse"}, + HasElse: true, + ThenBody: []ast.MicroflowStatement{ + &ast.ReturnStmt{Value: &ast.VariableExpr{Name: "ErrorResponse"}}, + }, + }, + }, + }, + }, + &ast.CallMicroflowStmt{ + MicroflowName: ast.QualifiedName{Module: "Synthetic", Name: "ContinueAfterPatch"}, + }, + &ast.ReturnStmt{Value: &ast.LiteralExpr{Kind: ast.LiteralNull}}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + declaredVars: map[string]string{"HasResponse": "Boolean", "ErrorResponse": "Synthetic.Error"}, + measurer: &layoutMeasurer{}, + } + oc := fb.buildFlowGraph(body, &ast.MicroflowReturnType{Type: ast.DataType{Kind: ast.TypeEntity, EntityRef: &ast.QualifiedName{Module: "Synthetic", Name: "Error"}}}) + + var splitID, continuationID model.ID + for _, obj := range oc.Objects { + switch o := obj.(type) { + case *microflows.ExclusiveSplit: + if condition, ok := o.SplitCondition.(*microflows.ExpressionSplitCondition); ok && condition.Expression == "$HasResponse" { + splitID = o.ID + } + case *microflows.ActionActivity: + if action, ok := o.Action.(*microflows.MicroflowCallAction); ok && action.MicroflowCall != nil && action.MicroflowCall.Microflow == "Synthetic.ContinueAfterPatch" { + continuationID = o.ID + } + } + } + if splitID == "" || continuationID == "" { + t.Fatalf("expected error-handler split and continuation call, got split=%q continuation=%q", splitID, continuationID) + } + + for _, flow := range oc.Flows { + if flow.OriginID != splitID || flowCaseString(flow.CaseValue) != "false" { + continue + } + if flow.DestinationID == continuationID || flowPathExists(oc.Flows, flow.DestinationID, continuationID) { + return + } + } + t.Fatal("expected deferred custom error-handler ELSE path to retain CaseValue=false when rejoining") +} + func TestBuildFlowGraph_ExplicitEmptyElseProvidesFalseContinuation(t *testing.T) { body := []ast.MicroflowStatement{ &ast.IfStmt{