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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/grammar", "date": "2026-10-16", "symptom": "`create workflow … call workflow M.Child … boundary event interrupting timer … { … };` fails to parse (\"mismatched input 'boundary' expecting ';'\", \"mismatched input 'interrupting' expecting SUBPROCESS\") while the identical clause after `call microflow` / `wait for notification` parses. DESCRIBE of a workflow whose call-workflow activity has a timer (Studio Pro-authored, or added by ALTER `insert into callX { boundary event … }`) prints that clause, so its describe output did not re-parse", "cause": "`workflowCallWorkflowStmt` (MDLWorkflow.g4) had no BOUNDARY EVENT clause and `ast.WorkflowCallWorkflowNode` no BoundaryEvents field, though the reader, writer, describer, ALTER mutator and MCP backend all handled Workflows$CallWorkflowActivity.boundaryEvents (10.14+, the same floor as every other owner). None of the AST walkers knew the case either: seven hand-written switches over the boundary-event owners (walkWorkflowActivities, collectEndBodies, the WF07 bare-timer count, WF15, the WF19 sites, the rewrite-guard count, the notification gate) listed user task / call microflow / wait only. Two SDK walkers had the same gap on the stored side: collectStoredPlacements (ALTER end ancestry lost the non-interrupting context under a call workflow) and collectJumpableNames (MDL-WF05 did not see activities inside a call-workflow boundary body)", "fix": "Grammar reuses call microflow's exact boundary clause (no new tokens); the visitor fills BoundaryEvents; buildCallWorkflowActivity writes them through buildBoundaryEvents. A new executor helper, workflowActivityBoundaryEvents, is the one place that lists the AST owners; the walkers that only need the events go through it, and the two that label by owner (WF15, WF19) gained the case. The stored walkers gained the CallWorkflowActivity case", "file": "mdl/grammar/domains/MDLWorkflow.g4; mdl/ast/ast_workflow.go; mdl/visitor/visitor_workflow.go; mdl/executor/cmd_workflows_write.go, validate_workflow.go (workflowActivityBoundaryEvents), validate_workflow_end.go, validate_workflow_esp.go, validate_workflow_scope.go, validate_workflow_refs.go, validate_workflow_rewrite.go, validate_workflow_jump.go; tests mdl/visitor/visitor_workflow_callworkflow_boundary_test.go, mdl/executor/workflow_callworkflow_boundary_test.go; mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl", "insight": "When the read side, describe and ALTER all handle a property and only CREATE cannot spell it, the gap is a round-trip break, not a missing feature: describe output is the repro. The grammar line was one; the cost was the walkers. Every rule that recurses into boundary bodies had its own owner switch, and adding an owner meant finding all of them: grep for the node types of the EXISTING owners (e.g. WorkflowWaitForNotificationNode), not the new one, since the new one appears in none of the switches that are wrong. Folding them onto one helper keeps the next owner from repeating it. Revert controls were per walker: each rule row failed only with its own walker reverted, which shows each case is load-bearing. Measured: interrupting and non-interrupting timer on call workflow, mx check 0 errors on 11.6.6 and 11.15.0, describe -> exec 'already in sync' on both", "refs": ["mendixlabs/mxcli#1532", "ako/mxcli#1414"]}
2 changes: 1 addition & 1 deletion .claude/skills/mendix/write-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ end workflow;
> the resulting `.mpr` **cannot be loaded at all**. `mxcli` refuses it (MDL-WF04)
> at check and exec time. Attach the note to an activity with `@annotation`.

**Boundary events** attach a timer to a user task / call-microflow / wait:
**Boundary events** attach a timer to a user task, call microflow, call workflow or wait:

```sql
mdl 1;
Expand Down
14 changes: 9 additions & 5 deletions cmd/mxcli/syntax/features_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ func init() {
Keywords: []string{
"call workflow", "sub-workflow", "nested workflow",
},
Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = <expression>)] [AS <name>] [CAPTION '<text>'];\n\n" +
Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = <expression>)] [AS <name>] [CAPTION '<text>']\n" +
" [BOUNDARY EVENT ...];\n\n" +
"-- A workflow has exactly one parameter and Mendix stores it as WorkflowContext,\n" +
"-- whatever name the called workflow's `PARAMETER $Name: ...` header declares — so\n" +
"-- the mapping key is always WorkflowContext. Any other key is CE1613 (MDL-WF18).\n" +
Expand All @@ -332,8 +333,11 @@ func init() {
Example: "CALL WORKFLOW HR.SubApproval CAPTION 'Delegate to sub-process';\n\n" +
"CALL WORKFLOW HR.SubApproval(WorkflowContext = $WorkflowContext) AS callWf1;\n\n" +
"-- pass an associated object: the path names the association AND its target entity\n" +
"CALL WORKFLOW HR.ReviewLine(WorkflowContext = $WorkflowContext/HR.Request_Line/HR.Line) AS callWf2;",
SeeAlso: []string{"workflow.create", "workflow.call-microflow", "microflow.workflow-actions"},
"CALL WORKFLOW HR.ReviewLine(WorkflowContext = $WorkflowContext/HR.Request_Line/HR.Line) AS callWf2;\n\n" +
"-- put a deadline on the child without moving the timer into it\n" +
"CALL WORKFLOW HR.SubApproval AS callWf3\n" +
" BOUNDARY EVENT INTERRUPTING TIMER addDays([%CurrentDateTime%], 5) { END WORKFLOW; };",
SeeAlso: []string{"workflow.create", "workflow.call-microflow", "workflow.boundary-event", "microflow.workflow-actions"},
})

Register(SyntaxFeature{
Expand Down Expand Up @@ -419,7 +423,7 @@ func init() {

Register(SyntaxFeature{
Path: "workflow.boundary-event",
Summary: "Attach boundary events (timer) to user tasks for timeouts",
Summary: "Attach boundary events (timer) to user tasks, calls and waits for timeouts",
Keywords: []string{
"boundary event", "timer", "timeout", "deadline",
"SLA", "escalation",
Expand All @@ -430,7 +434,7 @@ func init() {
// 'Workflows$TimerBoundaryEvent' could not be found"). This entry showed
// the bare form, with 'P3D' — not a valid timer expression — as its delay.
// ako/view-entity-examples FINDINGS §7.
Syntax: "-- inline, as a clause of a USER TASK (after OUTCOMES):\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER <datetime-expression> { <activities> }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION <name> ['<caption>'] { <activities> }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW <wf> { INSERT INTO <task> { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER <datetime-expression> { <activities> } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.",
Syntax: "-- inline, as a clause of a USER TASK or CALL MICROFLOW (after OUTCOMES),\n-- a CALL WORKFLOW or a WAIT FOR NOTIFICATION:\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER <datetime-expression> { <activities> }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION <name> ['<caption>'] { <activities> }\n\n-- A notification boundary event (Mendix 11.11+) is triggered by `notify workflow`\n-- targeting its name, so the name is unique in the workflow. ALTER cannot insert\n-- one yet; restate the workflow instead.\n\n-- or add a timer to an existing task:\nALTER WORKFLOW <wf> { INSERT INTO <task> { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER <datetime-expression> { <activities> } } };\n\n-- Name the kind: a bare TIMER is refused on Mendix 11 (MDL-WF07) — it writes a\n-- type the runtime cannot load. The delay is an expression that yields a\n-- DateTime, e.g. addDays([%CurrentDateTime%], 3). mxcli ends every boundary\n-- path with Mendix's end-of-path marker, so a path may end in a call; use\n-- JUMP TO to return to the task instead.",
Example: "user task ReviewTask 'Review'\n page Module.WF_Review\n outcomes 'Done' { }\n boundary event interrupting timer addDays([%CurrentDateTime%], 3) {\n call microflow Module.WF_Escalate;\n };",
MinVersion: "10.6.0",
SeeAlso: []string{"workflow.user-task", "workflow.notification", "workflow.event-subprocess"},
Expand Down
13 changes: 11 additions & 2 deletions docs-site/src/language/workflow-activities.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ branch with `OUTCOMES`; return nothing for a single path.
Start a sub-workflow:

```sql
CALL WORKFLOW <Module>.<Name> [(<Param> = <expression>, ...)] [CAPTION '<text>'];
CALL WORKFLOW <Module>.<Name> [(<Param> = <expression>, ...)] [AS <name>] [CAPTION '<text>']
[BOUNDARY EVENT ...];
```

Example:
Expand All @@ -146,6 +147,14 @@ Example:
CALL WORKFLOW HR.BackgroundCheck CAPTION 'Run background check sub-process';
```

A call workflow takes the same boundary events as a user task or call microflow,
so a parent can put a deadline on a child without moving the timer into it:

```sql
CALL WORKFLOW HR.BackgroundCheck AS callCheck
BOUNDARY EVENT INTERRUPTING TIMER addDays([%CurrentDateTime%], 5) { END WORKFLOW; };
```

## Decision

Branch the workflow based on a condition. The condition is a bare expression, Boolean or enumeration; each outcome contains a block of activities:
Expand Down Expand Up @@ -239,7 +248,7 @@ An intermediate notification event (Mendix 11.11+): the point a `NOTIFY WORKFLOW
NOTIFICATION [<name>] [CAPTION '<caption>'];
```

A **notification boundary event** attaches the same trigger to a user task, call microflow or wait. It takes a name instead of a timer delay:
A **notification boundary event** attaches the same trigger to a user task, call microflow, call workflow or wait. It takes a name instead of a timer delay:

```sql
USER TASK Review 'Review'
Expand Down
50 changes: 50 additions & 0 deletions mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
mdl 1;
-- ============================================================================
-- Regression: mendixlabs/mxcli#1532 - no boundary events on CALL WORKFLOW
-- ============================================================================
--
-- Symptom (before fix):
-- `call workflow … boundary event interrupting timer …` failed to parse:
-- mismatched input 'boundary' expecting ';'
-- mismatched input 'interrupting' expecting SUBPROCESS
-- The same clause after `call microflow` and `wait for notification` parsed,
-- and DESCRIBE printed it for a call workflow Studio Pro had given a timer —
-- so describe output of such a workflow did not re-parse.
--
-- After fix:
-- The clause is the one call microflow uses. Measured with mx check on
-- Mendix 11.6.6 and 11.15.0: both parents below build with 0 errors, and
-- describe -> exec of them reports "already in sync".
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe workflow BugTest1532.WF_ParentInt"
-- mxcli docker check -p app.mpr -- expect 0 errors
-- ============================================================================

create module BugTest1532;
create persistent entity BugTest1532.Item ( Name: String(100) );

create workflow BugTest1532.WF_Child
parameter $WorkflowContext: BugTest1532.Item
begin
wait for timer childWait addDays([%CurrentDateTime%], 1);
end workflow;

-- Interrupting: the deadline cancels the child and ends the parent.
create workflow BugTest1532.WF_ParentInt
parameter $WorkflowContext: BugTest1532.Item
begin
call workflow BugTest1532.WF_Child(WorkflowContext = $WorkflowContext) as callChild
boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; };
end workflow;

-- Non-interrupting: the reminder path runs alongside the child.
create workflow BugTest1532.WF_ParentNonInt
parameter $WorkflowContext: BugTest1532.Item
begin
call workflow BugTest1532.WF_Child(WorkflowContext = $WorkflowContext) as callChild
boundary event non interrupting timer addHours([%CurrentDateTime%], 4) {
wait for timer remindWait addHours([%CurrentDateTime%], 1);
};
end workflow;
1 change: 1 addition & 0 deletions mdl/ast/ast_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ type WorkflowCallWorkflowNode struct {
Workflow QualifiedName
Caption string
ParameterMappings []WorkflowParameterMappingNode
BoundaryEvents []WorkflowBoundaryEventNode // mendixlabs/mxcli#1532
}

func (n *WorkflowCallWorkflowNode) workflowActivityNode() {}
Expand Down
3 changes: 3 additions & 0 deletions mdl/executor/cmd_workflows_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,9 @@ func buildCallWorkflowActivity(n *ast.WorkflowCallWorkflowNode) *workflows.CallW
act.ParameterMappings = append(act.ParameterMappings, mapping)
}

// BoundaryEvents (mendixlabs/mxcli#1532)
act.BoundaryEvents = buildBoundaryEvents(n.BoundaryEvents)

return act
}

Expand Down
32 changes: 22 additions & 10 deletions mdl/executor/validate_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,6 @@ func walkWorkflowActivities(acts []ast.WorkflowActivityNode, visit func(ast.Work
for _, o := range n.Outcomes {
walkWorkflowActivities(o.Activities, visit)
}
for _, b := range n.BoundaryEvents {
walkWorkflowActivities(b.Activities, visit)
}
case *ast.WorkflowDecisionNode:
for _, o := range n.Outcomes {
walkWorkflowActivities(o.Activities, visit)
Expand All @@ -335,19 +332,34 @@ func walkWorkflowActivities(acts []ast.WorkflowActivityNode, visit func(ast.Work
for _, o := range n.Outcomes {
walkWorkflowActivities(o.Activities, visit)
}
for _, b := range n.BoundaryEvents {
walkWorkflowActivities(b.Activities, visit)
}
case *ast.WorkflowParallelSplitNode:
for _, p := range n.Paths {
walkWorkflowActivities(p.Activities, visit)
}
case *ast.WorkflowWaitForNotificationNode:
for _, b := range n.BoundaryEvents {
walkWorkflowActivities(b.Activities, visit)
}
}
for _, b := range workflowActivityBoundaryEvents(a) {
walkWorkflowActivities(b.Activities, visit)
}
}
}

// workflowActivityBoundaryEvents returns the boundary events an authored
// activity owns: user tasks, call microflow, call workflow and wait for
// notification. Every walk over boundary events goes through it — each used to
// switch on the owners itself, and when `call workflow` gained the clause
// (mendixlabs/mxcli#1532) eight of those switches had to learn it at once.
func workflowActivityBoundaryEvents(a ast.WorkflowActivityNode) []ast.WorkflowBoundaryEventNode {
switch n := a.(type) {
case *ast.WorkflowUserTaskNode:
return n.BoundaryEvents
case *ast.WorkflowCallMicroflowNode:
return n.BoundaryEvents
case *ast.WorkflowCallWorkflowNode:
return n.BoundaryEvents
case *ast.WorkflowWaitForNotificationNode:
return n.BoundaryEvents
}
return nil
}

// ValidateAlterWorkflow applies the rules that ALTER WORKFLOW can reach:
Expand Down
7 changes: 3 additions & 4 deletions mdl/executor/validate_workflow_end.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ func collectEndBodies(b endBody, out *[]endBody) {
for _, o := range n.Outcomes {
child(o.Activities, false, false)
}
boundary(n.BoundaryEvents)
case *ast.WorkflowDecisionNode:
for _, o := range n.Outcomes {
child(o.Activities, false, false)
Expand All @@ -69,14 +68,12 @@ func collectEndBodies(b endBody, out *[]endBody) {
for _, o := range n.Outcomes {
child(o.Activities, false, false)
}
boundary(n.BoundaryEvents)
case *ast.WorkflowParallelSplitNode:
for _, p := range n.Paths {
child(p.Activities, true, false)
}
case *ast.WorkflowWaitForNotificationNode:
boundary(n.BoundaryEvents)
}
boundary(workflowActivityBoundaryEvents(a))
}
}

Expand Down Expand Up @@ -368,6 +365,8 @@ func collectStoredPlacements(flow *workflows.Flow, ref string, at storedPlacemen
collectStoredPlacements(o.GetFlow(), ref, at, out)
}
boundary(t.BoundaryEvents)
case *workflows.CallWorkflowActivity:
boundary(t.BoundaryEvents)
case *workflows.WaitForNotificationActivity:
boundary(t.BoundaryEvents)
default:
Expand Down
14 changes: 4 additions & 10 deletions mdl/executor/validate_workflow_esp.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,10 @@ func workflowStatementActivities(stmt *ast.CreateWorkflowStmt) []ast.WorkflowAct
func workflowUsesNotificationEvents(activities []ast.WorkflowActivityNode) bool {
found := false
walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) {
var events []ast.WorkflowBoundaryEventNode
switch n := a.(type) {
case *ast.WorkflowNotificationNode:
if _, ok := a.(*ast.WorkflowNotificationNode); ok {
found = true
case *ast.WorkflowUserTaskNode:
events = n.BoundaryEvents
case *ast.WorkflowCallMicroflowNode:
events = n.BoundaryEvents
case *ast.WorkflowWaitForNotificationNode:
events = n.BoundaryEvents
}
for _, e := range events {
for _, e := range workflowActivityBoundaryEvents(a) {
if e.EventType == "InterruptingNotification" || e.EventType == "NonInterruptingNotification" {
found = true
}
Expand Down Expand Up @@ -101,6 +93,8 @@ func ValidateWorkflowEventSubProcesses(stmt *ast.CreateWorkflowStmt) []linter.Vi
events, label = n.BoundaryEvents, "user task "+workflowUserTaskLabel(n)
case *ast.WorkflowCallMicroflowNode:
events, label = n.BoundaryEvents, "call microflow "+workflowCallMicroflowLabel(n)
case *ast.WorkflowCallWorkflowNode:
events, label = n.BoundaryEvents, "call workflow "+n.Workflow.String()
case *ast.WorkflowWaitForNotificationNode:
name := n.Name
if name == "" {
Expand Down
1 change: 1 addition & 0 deletions mdl/executor/validate_workflow_jump.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func collectJumpableNames(acts []workflows.WorkflowActivity, out map[string]bool
add(a.Name)
case *workflows.CallWorkflowActivity:
add(a.Name)
collectBoundaryEventNames(a.BoundaryEvents, out)
case *workflows.ExclusiveSplitActivity:
add(a.Name)
collectConditionOutcomeNames(a.Outcomes, out)
Expand Down
9 changes: 1 addition & 8 deletions mdl/executor/validate_workflow_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,7 @@ func bareTimerBoundaryEventErrors(ctx *ExecContext, activities []ast.WorkflowAct
}
}
walkWorkflowActivities(activities, func(a ast.WorkflowActivityNode) {
switch t := a.(type) {
case *ast.WorkflowUserTaskNode:
count(t.BoundaryEvents)
case *ast.WorkflowCallMicroflowNode:
count(t.BoundaryEvents)
case *ast.WorkflowWaitForNotificationNode:
count(t.BoundaryEvents)
}
count(workflowActivityBoundaryEvents(a))
})
if n == 0 {
return nil
Expand Down
9 changes: 1 addition & 8 deletions mdl/executor/validate_workflow_rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,14 +257,7 @@ func countRawWorkflowNodes(v any, marker string) int {
func countAuthoredBoundaryEvents(activities []ast.WorkflowActivityNode) int {
n := 0
walkWorkflowActivities(activities, func(act ast.WorkflowActivityNode) {
switch a := act.(type) {
case *ast.WorkflowUserTaskNode:
n += len(a.BoundaryEvents)
case *ast.WorkflowCallMicroflowNode:
n += len(a.BoundaryEvents)
case *ast.WorkflowWaitForNotificationNode:
n += len(a.BoundaryEvents)
}
n += len(workflowActivityBoundaryEvents(act))
})
return n
}
Expand Down
1 change: 1 addition & 0 deletions mdl/executor/validate_workflow_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func workflowActivityExprSites(acts []ast.WorkflowActivityNode) []wfExprSite {
boundary(n.BoundaryEvents, "call microflow "+n.Microflow.String())
case *ast.WorkflowCallWorkflowNode:
mappings(n.ParameterMappings, "call workflow", n.Workflow.String())
boundary(n.BoundaryEvents, "call workflow "+n.Workflow.String())
case *ast.WorkflowDecisionNode:
sites = append(sites, wfExprSite{where: "decision " + workflowDecisionLabel(n), expr: n.Expression})
case *ast.WorkflowWaitForTimerNode:
Expand Down
Loading
Loading