From 38d68c53873ae98a510fdb5d48d4fd1a6a126c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:59:16 +0000 Subject: [PATCH] feat(workflow): boundary events on call workflow (mendixlabs/mxcli#1532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create workflow … call workflow M.Child … boundary event interrupting timer …` failed to parse ("mismatched input 'boundary' expecting ';'"), although the reader, writer, describer, ALTER mutator and MCP backend all handle Workflows$CallWorkflowActivity.boundaryEvents (10.14+, the same floor as every other owner). DESCRIBE printed the clause for such an activity, so its output did not re-parse. workflowCallWorkflowStmt now takes call microflow's exact boundary-event clause (no new tokens), the visitor fills a new BoundaryEvents field on WorkflowCallWorkflowNode, and buildCallWorkflowActivity writes them through buildBoundaryEvents. The AST walkers each switched on the boundary-event owners themselves and none knew call workflow, so WF07/08/09/10/15/18/19, the rewrite guard's count and the 11.11 notification gate would have skipped the new bodies. A single helper, workflowActivityBoundaryEvents, now lists the owners; the two walkers that label by owner (WF15, WF19) gained the case. On the stored side, ALTER's end-ancestry walk lost the non-interrupting context under a call workflow and MDL-WF05 did not see jump targets inside its boundary body; both gained the case. Measured: interrupting and non-interrupting timer boundary events on call workflow, mx check 0 errors on 11.6.6 and 11.15.0; describe -> exec "already in sync" on both. A notification boundary event on call workflow is 0 errors on 11.15.0 and refused by the existing 11.11 gate on 11.6.6. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- ...-workflow-boundary-events-unparseable.json | 1 + .../skills/mendix/write-workflows/SKILL.md | 2 +- cmd/mxcli/syntax/features_workflow.go | 14 +- docs-site/src/language/workflow-activities.md | 13 +- .../1532-call-workflow-boundary-events.mdl | 50 ++++ mdl/ast/ast_workflow.go | 1 + mdl/executor/cmd_workflows_write.go | 3 + mdl/executor/validate_workflow.go | 32 ++- mdl/executor/validate_workflow_end.go | 7 +- mdl/executor/validate_workflow_esp.go | 14 +- mdl/executor/validate_workflow_jump.go | 1 + mdl/executor/validate_workflow_refs.go | 9 +- mdl/executor/validate_workflow_rewrite.go | 9 +- mdl/executor/validate_workflow_scope.go | 1 + .../workflow_callworkflow_boundary_test.go | 228 ++++++++++++++++++ mdl/grammar/domains/MDLWorkflow.g4 | 5 + mdl/visitor/visitor_workflow.go | 5 + ...tor_workflow_callworkflow_boundary_test.go | 65 +++++ 18 files changed, 412 insertions(+), 48 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json create mode 100644 mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl create mode 100644 mdl/executor/workflow_callworkflow_boundary_test.go create mode 100644 mdl/visitor/visitor_workflow_callworkflow_boundary_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json new file mode 100644 index 0000000000..8d21730b17 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-16-call-workflow-boundary-events-unparseable.json @@ -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"]} diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 59cfb221bd..ea82bfe07b 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -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; diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index 0d2e78e407..74540d77a2 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -323,7 +323,8 @@ func init() { Keywords: []string{ "call workflow", "sub-workflow", "nested workflow", }, - Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = )] [AS ] [CAPTION ''];\n\n" + + Syntax: "CALL WORKFLOW Module.WF[(WorkflowContext = )] [AS ] [CAPTION '']\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" + @@ -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{ @@ -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", @@ -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 { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\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 { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { } } };\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 { }\nBOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [''] { }\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 { INSERT INTO { BOUNDARY EVENT (INTERRUPTING | NON INTERRUPTING) TIMER { } } };\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"}, diff --git a/docs-site/src/language/workflow-activities.md b/docs-site/src/language/workflow-activities.md index 72340f1c21..882f6f6713 100644 --- a/docs-site/src/language/workflow-activities.md +++ b/docs-site/src/language/workflow-activities.md @@ -137,7 +137,8 @@ branch with `OUTCOMES`; return nothing for a single path. Start a sub-workflow: ```sql -CALL WORKFLOW . [( = , ...)] [CAPTION '']; +CALL WORKFLOW . [( = , ...)] [AS ] [CAPTION ''] + [BOUNDARY EVENT ...]; ``` Example: @@ -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: @@ -239,7 +248,7 @@ An intermediate notification event (Mendix 11.11+): the point a `NOTIFY WORKFLOW NOTIFICATION [] [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' diff --git a/mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl b/mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl new file mode 100644 index 0000000000..b9bec32d31 --- /dev/null +++ b/mdl-examples/bug-tests/1532-call-workflow-boundary-events.mdl @@ -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; diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index fdd67216b3..7a71a2617d 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -172,6 +172,7 @@ type WorkflowCallWorkflowNode struct { Workflow QualifiedName Caption string ParameterMappings []WorkflowParameterMappingNode + BoundaryEvents []WorkflowBoundaryEventNode // mendixlabs/mxcli#1532 } func (n *WorkflowCallWorkflowNode) workflowActivityNode() {} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index bf59a5d714..290709bdc3 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -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 } diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index c767adbab8..70ec5ec75f 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -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) @@ -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: diff --git a/mdl/executor/validate_workflow_end.go b/mdl/executor/validate_workflow_end.go index e37a254939..ea8a7bc752 100644 --- a/mdl/executor/validate_workflow_end.go +++ b/mdl/executor/validate_workflow_end.go @@ -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) @@ -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)) } } @@ -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: diff --git a/mdl/executor/validate_workflow_esp.go b/mdl/executor/validate_workflow_esp.go index a783aa8335..787a86e008 100644 --- a/mdl/executor/validate_workflow_esp.go +++ b/mdl/executor/validate_workflow_esp.go @@ -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 } @@ -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 == "" { diff --git a/mdl/executor/validate_workflow_jump.go b/mdl/executor/validate_workflow_jump.go index 6715048088..8c93b0c8ee 100644 --- a/mdl/executor/validate_workflow_jump.go +++ b/mdl/executor/validate_workflow_jump.go @@ -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) diff --git a/mdl/executor/validate_workflow_refs.go b/mdl/executor/validate_workflow_refs.go index 1a647aed8b..ecef71b63d 100644 --- a/mdl/executor/validate_workflow_refs.go +++ b/mdl/executor/validate_workflow_refs.go @@ -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 diff --git a/mdl/executor/validate_workflow_rewrite.go b/mdl/executor/validate_workflow_rewrite.go index b5185fef33..cfffec6947 100644 --- a/mdl/executor/validate_workflow_rewrite.go +++ b/mdl/executor/validate_workflow_rewrite.go @@ -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 } diff --git a/mdl/executor/validate_workflow_scope.go b/mdl/executor/validate_workflow_scope.go index ebb6038492..9c431b2f4b 100644 --- a/mdl/executor/validate_workflow_scope.go +++ b/mdl/executor/validate_workflow_scope.go @@ -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: diff --git a/mdl/executor/workflow_callworkflow_boundary_test.go b/mdl/executor/workflow_callworkflow_boundary_test.go new file mode 100644 index 0000000000..cc94f61d1a --- /dev/null +++ b/mdl/executor/workflow_callworkflow_boundary_test.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// mendixlabs/mxcli#1532. A `call workflow` activity takes timer boundary events +// in Mendix (Workflows$CallWorkflowActivity.boundaryEvents, 10.14+, the same +// floor as every other boundary-event owner), and mxcli read, wrote, described +// and ALTERed them — but `create workflow … call workflow … boundary event …` +// did not parse, so describe output of such a workflow did not re-parse either. + +const cwBoundaryHead = "create workflow M.W parameter $WorkflowContext: M.Item\nbegin\n" + +func cwBoundaryWF(body string) string { return cwBoundaryHead + body + "\nend workflow;" } + +func firstCallWorkflow(t *testing.T, src string) *ast.WorkflowCallWorkflowNode { + t.Helper() + stmt := parseWorkflowStmt(t, src) + n, ok := stmt.Activities[0].(*ast.WorkflowCallWorkflowNode) + if !ok { + t.Fatalf("first activity is %T, want *ast.WorkflowCallWorkflowNode", stmt.Activities[0]) + } + return n +} + +// The builder writes the parsed boundary events onto the CallWorkflowActivity, +// each with its handler flow ended by Mendix's end-of-path marker. +func TestCallWorkflowBoundary_BuilderWritesBoundaryEvents(t *testing.T) { + n := firstCallWorkflow(t, cwBoundaryWF(`call workflow M.WF_Child(WorkflowContext = $WorkflowContext) as callChild + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; } + boundary event non interrupting timer addHours([%CurrentDateTime%], 4) { call microflow M.ACT_Remind; };`)) + if len(n.BoundaryEvents) != 2 { + t.Fatalf("parsed boundary events = %d, want 2", len(n.BoundaryEvents)) + } + + act := buildCallWorkflowActivity(n) + if len(act.BoundaryEvents) != 2 { + t.Fatalf("written boundary events = %d, want 2", len(act.BoundaryEvents)) + } + want := []struct{ typ, delay string }{ + {"InterruptingTimer", "addDays([%CurrentDateTime%], 1)"}, + {"NonInterruptingTimer", "addHours([%CurrentDateTime%], 4)"}, + } + for i, w := range want { + be := act.BoundaryEvents[i] + if be.EventType != w.typ || be.TimerDelay != w.delay { + t.Errorf("event %d = %s %q, want %s %q", i, be.EventType, be.TimerDelay, w.typ, w.delay) + } + if be.ID == "" { + t.Errorf("event %d has no ID", i) + } + if be.Flow == nil || len(be.Flow.Activities) == 0 { + t.Errorf("event %d has no handler flow", i) + } + } + if _, ok := act.BoundaryEvents[1].Flow.Activities[0].(*workflows.CallMicroflowTask); !ok { + t.Errorf("non-interrupting handler's first activity = %T, want the call microflow", act.BoundaryEvents[1].Flow.Activities[0]) + } + // The call itself is unchanged by the clause. + if act.Name != "callChild" || act.Workflow != "M.WF_Child" { + t.Errorf("call = %q -> %q, want callChild -> M.WF_Child", act.Name, act.Workflow) + } +} + +// Control: a call with no clause writes no boundary events. +func TestCallWorkflowBoundary_NoClauseNoEvents(t *testing.T) { + n := firstCallWorkflow(t, cwBoundaryWF(`call workflow M.WF_Child;`)) + if act := buildCallWorkflowActivity(n); len(act.BoundaryEvents) != 0 { + t.Errorf("boundary events = %d, want 0", len(act.BoundaryEvents)) + } +} + +// Every workflow rule walks nested flows through walkWorkflowActivities and its +// siblings; a boundary body under `call workflow` must be reached like one under +// `call microflow`. Each "flagged" row would be a build error; each control row +// is the same body where Mendix accepts it. +func TestCallWorkflowBoundary_ValidatorRecursesIntoBody(t *testing.T) { + cases := []struct { + name, body, rule string + want bool + }{ + // MDL-WF08 (CE1844): no end inside a non-interrupting boundary path. + {"WF08 end under non-interrupting", + `call workflow M.WF_Child boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; };`, + "MDL-WF08", true}, + {"WF08 control: end under interrupting", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; };`, + "MDL-WF08", false}, + // MDL-WF09 (CE6671): an activity after an end in the body. + {"WF09 activity after end in body", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; call microflow M.ACT_X; };`, + "MDL-WF09", true}, + // MDL-WF18 (CE1613): a nested call workflow keyed on a non-context name. + {"WF18 nested call workflow key", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call workflow M.WF_Other(Task = $WorkflowContext); };`, + "MDL-WF18", true}, + {"WF18 control: nested key is WorkflowContext", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call workflow M.WF_Other(WorkflowContext = $WorkflowContext); };`, + "MDL-WF18", false}, + // MDL-WF19 (CE0117): the timer expression and expressions in the body. + {"WF19 timer on $WorkflowUserTask", + `call workflow M.WF_Child boundary event interrupting timer addDays($WorkflowUserTask/StartTime, 1) { };`, + "MDL-WF19", true}, + {"WF19 argument in the body", + `call workflow M.WF_Child boundary event interrupting timer addDays([%CurrentDateTime%], 1) { call microflow M.ACT_X(Workflow = $Workflow); };`, + "MDL-WF19", true}, + {"WF19 control: timer on the workflow instance", + `call workflow M.WF_Child boundary event interrupting timer addDays($WorkflowInstance/StartTime, 1) { };`, + "MDL-WF19", false}, + // MDL-WF05: an activity inside the body is a jump target like any other. + {"WF05 control: jump to an activity in the body", + `call workflow M.WF_Child boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { user task Escalate 'Escalate' page M.P outcomes 'a' { } 'b' { }; }; + user task Next 'Next' page M.P outcomes 'a' { } 'b' { jump to Escalate; };`, + "MDL-WF05", false}, + // MDL-WF15: at most one interrupting boundary event per activity. + {"WF15 two interrupting", + `call workflow M.WF_Child + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { } + boundary event interrupting timer addDays([%CurrentDateTime%], 2) { };`, + "MDL-WF15", true}, + {"WF15 control: one of each", + `call workflow M.WF_Child + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 2) { };`, + "MDL-WF15", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got []linter.Violation + for _, v := range ValidateWorkflow(parseWorkflowStmt(t, cwBoundaryWF(c.body))) { + if v.RuleID == c.rule { + got = append(got, v) + } + } + if (len(got) > 0) != c.want { + t.Errorf("%s fired = %v, want %v: %v", c.rule, len(got) > 0, c.want, got) + } + }) + } +} + +// The helpers that count or classify boundary events feed the rewrite guard +// (#948), the bare-timer refusal (MDL-WF07) and the notification version gate. +func TestCallWorkflowBoundary_CountedAndClassified(t *testing.T) { + stmt := parseWorkflowStmt(t, cwBoundaryWF(`call workflow M.WF_Child + boundary event interrupting notification Withdrawn { } + boundary event non interrupting timer addDays([%CurrentDateTime%], 1) { };`)) + if n := countAuthoredBoundaryEvents(stmt.Activities); n != 2 { + t.Errorf("countAuthoredBoundaryEvents = %d, want 2", n) + } + if !workflowUsesNotificationEvents(stmt.Activities) { + t.Error("a notification boundary event on call workflow must trip the 11.11 notification gate") + } +} + +// Describe output of a call workflow with boundary events re-parses with both +// events intact — the round trip the missing grammar clause broke. +func TestCallWorkflowBoundary_DescribeReparses(t *testing.T) { + call := &workflows.CallWorkflowActivity{Workflow: "M.WF_Child", ParameterExpression: "$WorkflowContext"} + call.Name, call.Caption = "callChild", "Call child" + call.BoundaryEvents = []*workflows.BoundaryEvent{ + {EventType: "InterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 3)"}, + {EventType: "NonInterruptingTimer", TimerDelay: "addDays([%CurrentDateTime%], 1)"}, + } + body := strings.Join(formatSingleActivity(call, " "), "\n") + src := "create workflow M.W parameter $C: M.E\nbegin\n" + body + "\nend workflow;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("describe output does not parse: %v\n%s", errs, src) + } + n, ok := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowCallWorkflowNode) + if !ok { + t.Fatalf("re-parsed activity is not a call workflow\n%s", src) + } + if len(n.BoundaryEvents) != 2 { + t.Errorf("boundary events after re-parse = %d, want 2\n%s", len(n.BoundaryEvents), src) + } +} + +// ALTER's end-ancestry check reads the STORED workflow: a task inside a +// non-interrupting boundary path of a call workflow is "under a non-interrupting +// boundary" (CE1844 for an end there) exactly as under a user task's. +func TestCallWorkflowBoundary_StoredEndAncestry(t *testing.T) { + task := &workflows.UserTask{Outcomes: []*workflows.UserTaskOutcome{{Value: "a"}, {Value: "b"}}} + task.Name = "InCallBoundary" + call := &workflows.CallWorkflowActivity{Workflow: "WF.Child"} + call.Name = "callChild" + call.BoundaryEvents = []*workflows.BoundaryEvent{{ + EventType: "NonInterruptingTimer", + Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{task}}, + }} + wf := &workflows.Workflow{Name: "W", Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{call}}} + wf.ContainerID = "mod1" + mod := &model.Module{Name: "WF"} + mod.ID = "mod1" + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListWorkflowsFunc: func() ([]*workflows.Workflow, error) { return []*workflows.Workflow{wf}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + + for src, refused := range map[string]bool{ + `alter workflow WF.W insert outcome 'Stop' on InCallBoundary { end workflow; };`: true, + // Control: same target, nothing that ends. + `alter workflow WF.W insert outcome 'Stop' on InCallBoundary { };`: false, + } { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + got := validateAlterWorkflowEndAncestry(ctx, prog.Statements[0].(*ast.AlterWorkflowStmt)) + if (len(got) > 0) != refused { + t.Errorf("%s: refused = %v, want %v: %v", src, len(got) > 0, refused, got) + } + } +} diff --git a/mdl/grammar/domains/MDLWorkflow.g4 b/mdl/grammar/domains/MDLWorkflow.g4 index ebb2a074e6..d91bd647b8 100644 --- a/mdl/grammar/domains/MDLWorkflow.g4 +++ b/mdl/grammar/domains/MDLWorkflow.g4 @@ -306,9 +306,14 @@ workflowParameterMapping : qualifiedName EQUALS STRING_LITERAL ; +// A call-workflow activity owns boundary events like a call-microflow one +// (Workflows$CallWorkflowActivity.boundaryEvents, 10.14+). The reader, writer, +// describer and ALTER all handled them; only this rule did not, so describe +// output of such a workflow did not re-parse (mendixlabs/mxcli#1532). workflowCallWorkflowStmt : CALL WORKFLOW qualifiedName workflowCallArguments? (AS workflowActivityName)? workflowCaption? (WITH /* @alias MDL-DEPR008 */ LPAREN workflowParameterMapping (COMMA workflowParameterMapping)* RPAREN)? + (BOUNDARY EVENT workflowBoundaryEventClause ((BOUNDARY EVENT)? workflowBoundaryEventClause)*)? ; workflowDecisionStmt diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index bba45581c3..3877f76961 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -826,6 +826,11 @@ func buildWorkflowCallWorkflow(ctx parser.IWorkflowCallWorkflowStmtContext) *ast node.ParameterMappings = buildWorkflowParameterMappings(cwCtx.AllWorkflowParameterMapping()) } + // BoundaryEvents (mendixlabs/mxcli#1532) + for _, beCtx := range cwCtx.AllWorkflowBoundaryEventClause() { + node.BoundaryEvents = append(node.BoundaryEvents, buildBoundaryEventNode(beCtx)) + } + return node } diff --git a/mdl/visitor/visitor_workflow_callworkflow_boundary_test.go b/mdl/visitor/visitor_workflow_callworkflow_boundary_test.go new file mode 100644 index 0000000000..ef19fb48e6 --- /dev/null +++ b/mdl/visitor/visitor_workflow_callworkflow_boundary_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1532: `call workflow` takes the same boundary-event clause as +// `call microflow` and `wait for notification`. It was a parse error +// ("mismatched input 'boundary' expecting ';'"). +func TestWorkflowVisitor_CallWorkflowBoundaryEvents(t *testing.T) { + input := `create workflow Tmp.WF_Parent + parameter $WorkflowContext: Tmp.Item +begin + call workflow Tmp.WF_Child(WorkflowContext = $WorkflowContext) as callChild + boundary event interrupting timer addDays([%CurrentDateTime%], 1) { end workflow; } + non interrupting timer addHours([%CurrentDateTime%], 4) { call microflow Tmp.ACT_Remind; }; +end workflow;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt := prog.Statements[0].(*ast.CreateWorkflowStmt) + n, ok := stmt.Activities[0].(*ast.WorkflowCallWorkflowNode) + if !ok { + t.Fatalf("activity is %T, want *ast.WorkflowCallWorkflowNode", stmt.Activities[0]) + } + if n.Name != "callChild" || n.Workflow.String() != "Tmp.WF_Child" || len(n.ParameterMappings) != 1 { + t.Errorf("call = %q %s %d mappings, want callChild Tmp.WF_Child 1", n.Name, n.Workflow, len(n.ParameterMappings)) + } + if len(n.BoundaryEvents) != 2 { + t.Fatalf("boundary events = %d, want 2", len(n.BoundaryEvents)) + } + if be := n.BoundaryEvents[0]; be.EventType != "InterruptingTimer" || be.Delay != "addDays([%CurrentDateTime%], 1)" || len(be.Activities) != 1 { + t.Errorf("event 0 = %+v", be) + } + if be := n.BoundaryEvents[1]; be.EventType != "NonInterruptingTimer" || len(be.Activities) != 1 { + t.Errorf("event 1 = %+v", be) + } + if _, ok := n.BoundaryEvents[1].Activities[0].(*ast.WorkflowCallMicroflowNode); !ok { + t.Errorf("event 1 body = %T, want call microflow", n.BoundaryEvents[1].Activities[0]) + } +} + +// The deprecated `with (…)` mapping form still parses with a boundary clause +// after it, as it does on call microflow. +func TestWorkflowVisitor_CallWorkflowBoundaryAfterWith(t *testing.T) { + input := `create workflow Tmp.WF_Parent +begin + call workflow Tmp.WF_Child with (WorkflowContext = '$WorkflowContext') + boundary event non interrupting timer '${PT1H}'; +end workflow;` + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + n := prog.Statements[0].(*ast.CreateWorkflowStmt).Activities[0].(*ast.WorkflowCallWorkflowNode) + if len(n.BoundaryEvents) != 1 || len(n.ParameterMappings) != 1 { + t.Errorf("boundary events = %d, mappings = %d, want 1 and 1", len(n.BoundaryEvents), len(n.ParameterMappings)) + } +}