From f2d674e5f49bc78e59acb64718c4f77b06c89dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 13:36:55 +0000 Subject: [PATCH 01/16] pages: alter page insert into a layout grid appends rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `alter page M.P { insert into lgMain { row { column { … } } } }` failed with "row needs a name" and, named, "not a simple container": the INSERT's nodes were built as widgets and appended to a Widgets list, and a layout grid row is neither. Rows now get their own path, like tab pages (#1215): built with the CREATE PAGE row builder and appended to the grid's Rows through InsertLayoutGridRows. A row has no stored name, so BEFORE/AFTER is refused naming the INTO form, and mixing rows with widgets in one INSERT is refused. `syntax page.alter` shows the form. Verified on TestApp: both reported forms append, describe -> create or modify reports Unchanged, and mx check reports 0 errors. Fixes #2071 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82qrr1WnXxq1eRn7qRJ --- ...1-10-alter-page-insert-layoutgrid-row.json | 1 + CHANGELOG.md | 1 + cmd/mxcli/syntax/features_page.go | 2 +- mdl/backend/mcp/page_mutator.go | 4 + mdl/backend/mock/mock_page_mutator.go | 8 ++ mdl/backend/mutation.go | 6 ++ mdl/backend/pagemutator/layoutgridrows.go | 82 +++++++++++++++ .../pagemutator/layoutgridrows_test.go | 80 +++++++++++++++ mdl/executor/cmd_alter_page.go | 69 +++++++++++++ ...cmd_alter_page_layoutgrid_row_1791_test.go | 99 +++++++++++++++++++ 10 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-10-alter-page-insert-layoutgrid-row.json create mode 100644 mdl/backend/pagemutator/layoutgridrows.go create mode 100644 mdl/backend/pagemutator/layoutgridrows_test.go create mode 100644 mdl/executor/cmd_alter_page_layoutgrid_row_1791_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-alter-page-insert-layoutgrid-row.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-alter-page-insert-layoutgrid-row.json new file mode 100644 index 000000000..bdd8ad028 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-alter-page-insert-layoutgrid-row.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-10", "symptom": "ako/mxcli#2071 (mendixlabs/mxcli#1791): `alter page M.P { insert into lgMain { row { column { … } } } }` failed with 'row needs a name'; with `row r2` it failed with 'it is not a simple container'. Only INSERT AFTER a widget inside a column worked.", "cause": "An INSERT's nodes were built one by one as widgets, and a layout grid row is not a widget: the generic builder demands a name it never stores, and InsertWidget appends to a Widgets list a layout grid does not have. Tab pages had the same shape and got their own path in #1215.", "file": "mdl/executor/cmd_alter_page.go (allLayoutGridRows), mdl/backend/pagemutator/layoutgridrows.go (InsertLayoutGridRows)", "insight": "Every non-widget child kind (tab page, list view template, navigation list item, now layout grid row) needs its own insert path; the tell is a list key other than Widgets. A row has no stored Name, so INTO the grid is its only position: refuse BEFORE/AFTER with the INTO form named rather than invent an address. Verified on TestApp: both reported forms append, describe → create or modify reports Unchanged, mx check 0 errors.", "refs": ["ako/mxcli#2071", "mendixlabs/mxcli#1791", "mendixlabs/mxcli#1215"], "test": "mdl/executor/cmd_alter_page_layoutgrid_row_1791_test.go, mdl/backend/pagemutator/layoutgridrows_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3bdb712..a36cb94fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`alter page … insert into { row { column { … } } }` appends a row** (ako/mxcli#2071, mendixlabs/mxcli#1791) — it failed with "row needs a name", and with a name with "not a simple container", so a row could only be added by recreating the page. Rows are now built as layout grid rows and appended to the grid, as tab pages are to a tab container (#1215). A row has no stored name, so `insert before/after` cannot place one and says to use `insert into` the grid; `mxcli syntax page.alter` shows the form. - **`move scheduled event M.Nightly` moves the scheduled event when a microflow shares its name** (ako/mxcli#2072, mendixlabs/mxcli#1792) — the move looked the name up regardless of type, met the microflow first and refused with "M.Nightly is a microflow, not a scheduled event". It now picks the document of the type the statement names; a name no document of that type has is still refused, naming what it is. - **Lint ARCH001 no longer flags a data view on another module's entity that feeds that module's own snippet or page** (ako/mxcli#2069) — a page hosting Conversational UI's chat needs a data view on `ConversationalUI.ChatContext`, because the chat snippet takes it as its parameter, and every such page was told to use a view entity or microflow instead. A cross-module entity is now exempt when the page calls a snippet, or links to a page, of that entity's module that takes the entity as a parameter. Any other cross-module data view is still reported. - **`check` resolves a document by the name an earlier `rename` or `move` in the same script gives it** (ako/mxcli#2070, mendixlabs/mxcli#1790) — `rename page M.Old_Page to New_Page;` followed by `show page M.New_Page` was refused with "page not found: M.New_Page", although exec ran it; the script had to be split in two. A rename or a move to another module now counts as providing that document to every reference check, as a create does. diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index f6b3cd71c..8d32b0c7a 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -343,7 +343,7 @@ LIST IMPACT OF htmlelement; "drop template", "insert template", "list view template", "add parameter", "drop parameter", "page parameter", "add parameters", }, - Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `list design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (RenderMode: H2) ON txtTitle; -- dynamic text: Text | Paragraph | H1..H6\n SET (NativeTextStyle: Heading2) ON txtTitle; -- dynamic text on a native page:\n -- Text | Heading1..Heading6\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n SET ('Background color': 'Background Primary'); -- the page's own design\n -- property: no ON; OFF removes a toggle\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n ADD PARAMETERS $Order: Module.Order; -- or a primitive: $Count: Integer.\n -- A page with a Url needs a {Order}\n -- segment: SET (Url: '…/{Order}') in the\n -- same statement (CE5601 otherwise).\n -- A snippet takes entities only (MDL087).\n DROP PARAMETERS $Order; -- refused while a data source or an\n -- expression on the page still uses it\n ADD VARIABLES $show: Boolean = true;\n DROP VARIABLES $show;\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match; or a\n-- navigation list item: nav item(widgetInIt), nav item('Text'), nav item@n. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", + Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `list design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (RenderMode: H2) ON txtTitle; -- dynamic text: Text | Paragraph | H1..H6\n SET (NativeTextStyle: Heading2) ON txtTitle; -- dynamic text on a native page:\n -- Text | Heading1..Heading6\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n SET ('Background color': 'Background Primary'); -- the page's own design\n -- property: no ON; OFF removes a toggle\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n INSERT INTO layoutGridName { ROW { COLUMN { } } }; -- appends rows;\n -- a row has no stored name, so only INTO\n -- the grid places one (not BEFORE/AFTER)\n INSERT INTO tabContainerName { TABPAGE tp (Caption: 'Two') { } };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n ADD PARAMETERS $Order: Module.Order; -- or a primitive: $Count: Integer.\n -- A page with a Url needs a {Order}\n -- segment: SET (Url: '…/{Order}') in the\n -- same statement (CE5601 otherwise).\n -- A snippet takes entities only (MDL087).\n DROP PARAMETERS $Order; -- refused while a data source or an\n -- expression on the page still uses it\n ADD VARIABLES $show: Boolean = true;\n DROP VARIABLES $show;\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match; or a\n-- navigation list item: nav item(widgetInIt), nav item('Text'), nav item@n. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", Example: "ALTER PAGE Module.EditPage {\n SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Attribute: MiddleName)\n };\n DROP txtUnused;\n};", SeeAlso: []string{"page.create", "page.show", "snippet.alter"}, }) diff --git a/mdl/backend/mcp/page_mutator.go b/mdl/backend/mcp/page_mutator.go index 2a234e8c4..ec6ca00dd 100644 --- a/mdl/backend/mcp/page_mutator.go +++ b/mdl/backend/mcp/page_mutator.go @@ -509,6 +509,10 @@ func (m *mcpPageMutator) InsertTabPages(targetRef string, _ backend.InsertPositi return fmt.Errorf("adding tab pages at %s is not yet supported by the MCP backend", targetRef) } +func (m *mcpPageMutator) InsertLayoutGridRows(gridRef string, _ []*pages.LayoutGridRow) error { + return fmt.Errorf("adding layout grid rows to %s is not yet supported by the MCP backend", gridRef) +} + func (m *mcpPageMutator) InsertNavigationListItems(targetRef string, _ backend.InsertPosition, _ []*pages.NavigationListItem) error { return fmt.Errorf("adding navigation list items at %s is not yet supported by the MCP backend", targetRef) } diff --git a/mdl/backend/mock/mock_page_mutator.go b/mdl/backend/mock/mock_page_mutator.go index 915008bb5..04a447363 100644 --- a/mdl/backend/mock/mock_page_mutator.go +++ b/mdl/backend/mock/mock_page_mutator.go @@ -33,6 +33,7 @@ type MockPageMutator struct { InsertColumnsFunc func(gridRef, afterColumnRef string, position backend.InsertPosition, columns []*backend.DataGridColumnSpec) error InsertListViewTemplatesFunc func(listViewRef string, templates []*pages.ListViewTemplate) error InsertTabPagesFunc func(targetRef string, position backend.InsertPosition, tabPages []*pages.TabPage) error + InsertLayoutGridRowsFunc func(gridRef string, rows []*pages.LayoutGridRow) error InsertNavigationListItemsFunc func(targetRef string, position backend.InsertPosition, items []*pages.NavigationListItem) error ReplaceNavigationListItemFunc func(targetRef string, items []*pages.NavigationListItem) error DropListViewTemplateFunc func(listViewRef, specialization string) error @@ -151,6 +152,13 @@ func (m *MockPageMutator) InsertListViewTemplates(listViewRef string, templates return fmt.Errorf("MockBackend.InsertListViewTemplates not configured") } +func (m *MockPageMutator) InsertLayoutGridRows(gridRef string, rows []*pages.LayoutGridRow) error { + if m.InsertLayoutGridRowsFunc != nil { + return m.InsertLayoutGridRowsFunc(gridRef, rows) + } + return fmt.Errorf("MockBackend.InsertLayoutGridRows not configured") +} + func (m *MockPageMutator) InsertTabPages(targetRef string, position backend.InsertPosition, tabPages []*pages.TabPage) error { if m.InsertTabPagesFunc != nil { return m.InsertTabPagesFunc(targetRef, position, tabPages) diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 1c9271fc7..b4d3f5e80 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -164,6 +164,12 @@ type PageMutator interface { // cannot go through InsertWidget (#1215). InsertTabPages(targetRef string, position InsertPosition, tabPages []*pages.TabPage) error + // InsertLayoutGridRows appends rows to a layout grid. A row lives in the + // grid's Rows list and is not a widget, so it cannot go through + // InsertWidget; it has no stored Name, so INTO the grid is the only + // position (mendixlabs/mxcli#1791). + InsertLayoutGridRows(gridRef string, rows []*pages.LayoutGridRow) error + // InsertNavigationListItems adds items to a navigation list: INTO the list // appends, BEFORE/AFTER an item places them next to it. An item lives in the // list's Items and is not a widget, so it cannot go through InsertWidget. It diff --git a/mdl/backend/pagemutator/layoutgridrows.go b/mdl/backend/pagemutator/layoutgridrows.go new file mode 100644 index 000000000..e992b5b0a --- /dev/null +++ b/mdl/backend/pagemutator/layoutgridrows.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/sdk/pages" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// isLayoutGrid reports whether a stored widget is a layout grid. +func isLayoutGrid(widget bson.D) bool { + switch widgetTypeName(widget) { + case "Forms$LayoutGrid", "Pages$LayoutGrid": + return true + } + return false +} + +// InsertLayoutGridRows appends rows to an existing layout grid +// (mendixlabs/mxcli#1791). +// +// A row lives in the grid's Rows list, not in a Widgets list, and is not a +// widget — the reason INSERT INTO refused a layout grid as "not a simple +// container", as it did a tab container before #1215. A row has no stored Name, +// so it cannot be the target of BEFORE/AFTER; INTO the grid, which appends, is +// the only position. +// +// The rows are serialized by wrapping them in a throwaway layout grid and +// taking its Rows back out, so they get exactly the shape CREATE PAGE writes. +func (m *Mutator) InsertLayoutGridRows(gridRef string, rows []*pages.LayoutGridRow) error { + result := m.widgetFinder(m.rawData, gridRef) + if result == nil { + return m.widgetNotFoundError(gridRef) + } + if !isLayoutGrid(result.widget) { + return fmt.Errorf("cannot insert a row into %q (%s): a row can only be added to a layout grid", + gridRef, widgetTypeName(result.widget)) + } + if len(rows) == 0 { + return fmt.Errorf("no rows to insert") + } + wrapper := &pages.LayoutGrid{Rows: rows} + wrapper.TypeName = widgetTypeName(result.widget) + serialized := bsonnav.ToBsonA(bsonnav.DGet(m.deps.SerializeWidget(wrapper), "Rows")) + var newDocs []any + marker := any(nil) + for _, el := range serialized { + if isListMarker(el) { + marker = el + continue + } + if d, ok := el.(bson.D); ok { + newDocs = append(newDocs, d) + } + } + if len(newDocs) != len(rows) { + return fmt.Errorf("serialize layout grid rows: got %d stored rows for %d inserted", len(newDocs), len(rows)) + } + + grid := result.widget + stored := bsonnav.ToBsonA(bsonnav.DGet(grid, "Rows")) + var out bson.A + switch { + case len(stored) > 0: + out = append(out, stored...) // marker + existing rows, kept as stored + case marker != nil: + out = append(out, marker) // an empty grid takes the marker CREATE PAGE writes + } + out = append(out, newDocs...) + if !bsonnav.DSet(grid, "Rows", out) { + grid = append(grid, bson.E{Key: "Rows", Value: out}) + } + if result.parentArr == nil || result.index < 0 || result.index >= len(result.parentArr) { + return fmt.Errorf("cannot add rows to %q: its parent slot could not be located", gridRef) + } + result.parentArr[result.index] = grid + bsonnav.DSetArray(result.parentDoc, result.parentKey, result.parentArr) + return nil +} diff --git a/mdl/backend/pagemutator/layoutgridrows_test.go b/mdl/backend/pagemutator/layoutgridrows_test.go new file mode 100644 index 000000000..4ccfa9ae3 --- /dev/null +++ b/mdl/backend/pagemutator/layoutgridrows_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// rowStubDeps serializes a layout grid the way the codec does: a +// Forms$LayoutGrid whose Rows carry a list marker and one doc per row. +type rowStubDeps struct{ stubWidgetDeps } + +func (d *rowStubDeps) SerializeWidget(w pages.Widget) bson.D { + lg, ok := w.(*pages.LayoutGrid) + if !ok { + return d.stubWidgetDeps.SerializeWidget(w) + } + arr := bson.A{int32(2)} + for _, r := range lg.Rows { + arr = append(arr, bson.D{{Key: "$Type", Value: "Forms$LayoutGridRow"}, {Key: "Class", Value: r.Class}}) + } + return bson.D{{Key: "$Type", Value: "Forms$LayoutGrid"}, {Key: "Rows", Value: arr}} +} + +func makeLayoutGridPage(rows ...bson.D) bson.D { + arr := bson.A{int32(2)} + for _, r := range rows { + arr = append(arr, r) + } + return makeRawPage(bson.D{ + {Key: "$Type", Value: "Forms$LayoutGrid"}, + {Key: "Name", Value: "lgMain"}, + {Key: "Rows", Value: arr}, + }, bson.D{{Key: "$Type", Value: "Forms$DivContainer"}, {Key: "Name", Value: "div1"}, {Key: "Widgets", Value: bson.A{int32(2)}}}) +} + +func rowClasses(t *testing.T, m *Mutator) []string { + t.Helper() + var out []string + rows := bsonnav.ToBsonA(bsonnav.DGet(findBsonWidget(m.rawData, "lgMain").widget, "Rows")) + if len(rows) == 0 || !isListMarker(rows[0]) { + t.Fatalf("Rows lost its list marker: %v", rows) + } + for _, el := range rows[1:] { + out = append(out, bsonnav.DGetString(el.(bson.D), "Class")) + } + return out +} + +// mendixlabs/mxcli#1791: rows are appended to the grid's Rows list, after the +// stored ones, keeping its marker. +func TestInsertLayoutGridRows_AppendsAfterStoredRows(t *testing.T) { + existing := bson.D{{Key: "$Type", Value: "Forms$LayoutGridRow"}, {Key: "Class", Value: "old"}} + m := New(makeLayoutGridPage(existing), model.ID("page-1"), &rowStubDeps{}) + rows := []*pages.LayoutGridRow{{Class: "new1"}, {Class: "new2"}} + if err := m.InsertLayoutGridRows("lgMain", rows); err != nil { + t.Fatal(err) + } + if got := strings.Join(rowClasses(t, m), ","); got != "old,new1,new2" { + t.Errorf("rows = %s, want old,new1,new2", got) + } +} + +func TestInsertLayoutGridRows_RefusesANonGrid(t *testing.T) { + m := New(makeLayoutGridPage(), model.ID("page-1"), &rowStubDeps{}) + err := m.InsertLayoutGridRows("div1", []*pages.LayoutGridRow{{}}) + if err == nil || !strings.Contains(err.Error(), "only be added to a layout grid") { + t.Fatalf("err = %v, want a refusal for a container that is not a layout grid", err) + } + if err := m.InsertLayoutGridRows("nope", []*pages.LayoutGridRow{{}}); err == nil { + t.Fatal("an unknown grid was accepted") + } +} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 8bd6d9276..968e42620 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -765,6 +765,32 @@ func applyInsertWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op "Use one INSERT for the tab pages and another for the widgets") } + // Special path: adding rows to a layout grid (mendixlabs/mxcli#1791). A row + // lives in the grid's Rows list and is not a widget: the builder asked for + // a name it never stores, and InsertWidget refused the grid as "not a + // simple container". A row has no stored name, so INTO the grid is the only + // position the mutator can address. + if allLayoutGridRows(op.Widgets) { + if !into { + return mdlerrors.NewValidation(fmt.Sprintf( + "a layout grid row has no name to insert %s, so rows are added with "+ + "`insert into { row { … } }`, which appends them; to place widgets "+ + "in an existing row, insert into or next to a widget in its column", + strings.ToLower(op.Position))) + } + rows, err := buildLayoutGridRowsFromAST(ctx, op.Widgets, moduleName, moduleID, entityCtx, mutator) + if err != nil { + return mdlerrors.NewBackend("build layout grid rows", err) + } + return mutator.InsertLayoutGridRows(op.Target.Widget, rows) + } + if hasLayoutGridRow(op.Widgets) { + return mdlerrors.NewValidation( + "mixing `row` blocks with ordinary widgets in one INSERT is not supported: " + + "rows go in a layout grid's list of rows, widgets inside a row's column. " + + "Use one INSERT for the rows and another for the widgets") + } + // Special path: adding items to a navigation list (#1388). An item lives in // the list's Items and is not a widget, so the builder refuses one on its own // ("item must be a direct child of navigationlist") and InsertWidget would @@ -1364,6 +1390,49 @@ func hasNavigationListItem(widgets []*ast.WidgetV3) bool { return false } +// buildLayoutGridRowsFromAST builds the rows an INSERT adds to a layout grid, +// with the same builder CREATE PAGE uses for a layout grid's children. +func buildLayoutGridRowsFromAST(ctx *ExecContext, nodes []*ast.WidgetV3, moduleName string, moduleID model.ID, entityContext string, mutator backend.PageMutator) ([]*pages.LayoutGridRow, error) { + pb := newAlterPageBuilder(ctx, moduleName, moduleID, entityContext, mutator) + pb.isDocumentWidget = alterDocumentWidgets(mutator, nodes) + out := make([]*pages.LayoutGridRow, 0, len(nodes)) + for _, node := range nodes { + if err := refuseNativeWidgetProps(pb.ctx, "layoutgrid row", node); err != nil { + return nil, err + } + row, err := pb.buildLayoutGridRowV3(node) + if err != nil { + return nil, err + } + out = append(out, row) + } + return out, nil +} + +// allLayoutGridRows reports whether every inserted node is a layout grid row. +func allLayoutGridRows(widgets []*ast.WidgetV3) bool { + if len(widgets) == 0 { + return false + } + for _, w := range widgets { + if !strings.EqualFold(w.Type, "row") { + return false + } + } + return true +} + +// hasLayoutGridRow reports whether ANY inserted node is a layout grid row, so +// a mixed insert is refused rather than half-applied. +func hasLayoutGridRow(widgets []*ast.WidgetV3) bool { + for _, w := range widgets { + if strings.EqualFold(w.Type, "row") { + return true + } + } + return false +} + // allTabPages reports whether every inserted node is a tab page. func allTabPages(widgets []*ast.WidgetV3) bool { if len(widgets) == 0 { diff --git a/mdl/executor/cmd_alter_page_layoutgrid_row_1791_test.go b/mdl/executor/cmd_alter_page_layoutgrid_row_1791_test.go new file mode 100644 index 000000000..ff914127d --- /dev/null +++ b/mdl/executor/cmd_alter_page_layoutgrid_row_1791_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +func layoutRowNode(name string) *ast.WidgetV3 { + return &ast.WidgetV3{ + Type: "row", Name: name, + Children: []*ast.WidgetV3{{ + Type: "column", + Children: []*ast.WidgetV3{{ + Type: "dynamictext", Name: "t1", Properties: map[string]any{"Content": "x"}, + }}, + }}, + } +} + +// ako/mxcli#2071 (mendixlabs/mxcli#1791): `alter page M.P { insert into lgMain +// { row { column { … } } } }` failed — first "row needs a name" (the row was +// built as a widget), then, named, "not a simple container" (InsertWidget has +// no Rows list to append to). A row, named or not, must be built as a layout +// grid row and handed to InsertLayoutGridRows. +func TestAlterPageInsertLayoutGridRow_RoutesToRows(t *testing.T) { + for _, name := range []string{"", "r2"} { + t.Run("name="+name, func(t *testing.T) { + var gotGrid string + var gotRows []*pages.LayoutGridRow + mutator := &mock.MockPageMutator{ + InsertLayoutGridRowsFunc: func(grid string, rows []*pages.LayoutGridRow) error { + gotGrid, gotRows = grid, rows + return nil + }, + InsertWidgetFunc: func(string, string, backend.InsertPosition, []pages.Widget) error { + t.Error("a row went through InsertWidget — it would land in a widget list") + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "INTO", + Target: ast.WidgetRef{Widget: "lgMain"}, + Widgets: []*ast.WidgetV3{layoutRowNode(name)}, + }) + if err != nil { + t.Fatalf("insert into lgMain { row %s }: %v", name, err) + } + if gotGrid != "lgMain" || len(gotRows) != 1 { + t.Fatalf("InsertLayoutGridRows(%q, %d rows), want (lgMain, 1)", gotGrid, len(gotRows)) + } + row := gotRows[0] + if row.TypeName != "Forms$LayoutGridRow" || len(row.Columns) != 1 || len(row.Columns[0].Widgets) != 1 { + t.Errorf("row = %s with %d columns, want a Forms$LayoutGridRow with one column holding the text", row.TypeName, len(row.Columns)) + } + }) + } +} + +// A row has no stored name, so BEFORE/AFTER cannot place one; and a row and a +// widget go to different lists, so one INSERT cannot mean both. Both are +// refused, naming what to write instead, rather than half-applied. +func TestAlterPageInsertLayoutGridRow_Refusals(t *testing.T) { + text := &ast.WidgetV3{Type: "dynamictext", Name: "t9", Properties: map[string]any{"Content": "y"}} + for _, tc := range []struct { + name string + position string + widgets []*ast.WidgetV3 + want string + }{ + {"after a widget", "AFTER", []*ast.WidgetV3{layoutRowNode("")}, "insert into "}, + {"rows mixed with a widget", "INTO", []*ast.WidgetV3{layoutRowNode(""), text}, "mixing `row` blocks"}, + } { + t.Run(tc.name, func(t *testing.T) { + mutator := &mock.MockPageMutator{ + InsertLayoutGridRowsFunc: func(string, []*pages.LayoutGridRow) error { + t.Error("a refused insert reached InsertLayoutGridRows") + return nil + }, + InsertWidgetFunc: func(string, string, backend.InsertPosition, []pages.Widget) error { + t.Error("a refused insert reached InsertWidget") + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: tc.position, Target: ast.WidgetRef{Widget: "t0"}, Widgets: tc.widgets, + }) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want one containing %q", err, tc.want) + } + }) + } +} From e1721b0aefeaf23eebebaafdf8c1598cf49d3ef6 Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 13:39:38 +0000 Subject: [PATCH 02/16] feat(catalog): sequence flows and activity positions for lint rules (#1299) A Starlark rule could not see which activity leads to which, so it could not tie a log to its custom error handler, an annotation to the activity it explains, or a split's branch to its case value. activities_for() had no canvas position either. CATALOG.FLOWS holds one row per sequence and annotation flow of a microflow, nanoflow or rule, with origin and destination activity Ids, IsErrorHandler, CaseValue and the connection sides. flows_for() exposes it to rules, and activities gain X, Y, Width and Height. Catalog schema 44. Co-Authored-By: Claude Opus 5.5 --- ...6-11-10-sequence-flows-not-in-catalog.json | 1 + .../skills/mendix/write-lint-rules/SKILL.md | 34 +++- CHANGELOG.md | 1 + docs-site/src/tools/catalog-tables.md | 34 ++++ docs-site/src/tools/custom-rules.md | 2 +- .../bug-tests/1299-sequence-flows.mdl | 34 ++++ mdl/catalog/builder_flows_test.go | 170 ++++++++++++++++++ mdl/catalog/builder_microflows.go | 113 +++++++++++- mdl/catalog/catalog.go | 1 + mdl/catalog/tables.go | 38 +++- mdl/executor/cmd_catalog.go | 1 + mdl/linter/context.go | 64 ++++++- mdl/linter/starlark.go | 43 ++++- .../starlark_catalog_mode_guard_test.go | 1 + mdl/linter/starlark_flow_noun_test.go | 2 +- mdl/linter/starlark_flows_for_test.go | 108 +++++++++++ 16 files changed, 631 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-11-10-sequence-flows-not-in-catalog.json create mode 100644 mdl-examples/bug-tests/1299-sequence-flows.mdl create mode 100644 mdl/catalog/builder_flows_test.go create mode 100644 mdl/linter/starlark_flows_for_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-11-10-sequence-flows-not-in-catalog.json b/.claude/skills/fix-issue/findings/mdl-other/2026-11-10-sequence-flows-not-in-catalog.json new file mode 100644 index 000000000..13d5311ec --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-11-10-sequence-flows-not-in-catalog.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-11-10", "symptom": "A Starlark rule could not see a microflow's sequence flows (which log belongs to which custom error handler, which activity an annotation is attached to, which split branch is true) nor where an object sits on the canvas: flows_for() was undefined and activities had no x/y.", "cause": "The reader decodes ObjectCollection.Flows and AnnotationFlows and each object's RelativeMiddlePoint and Size (describe prints them as on error, @annotation and @position), but the catalog builder walked Objects only.", "file": "mdl/catalog/builder_microflows.go; mdl/linter/context.go; mdl/linter/starlark.go", "insight": "Measure where the flows live before walking: on 11.12 every flow, loop bodies' included, is on the document's top-level Flows (bson dump --format ndsl), but an object's RelativeMiddlePoint inside a loop is relative to the loop's box, so x/y compare only within one parent_loop_id. A boolean split's branch reads back as an EnumerationCase with Value 'true'/'false', not a BooleanCase. On the Studio Pro-authored TestApp all 1476 flow ends resolve to an activities Id.", "issue": "ako/mxcli#1299"} diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 895a4a72d..05a9fcc1b 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -11,11 +11,11 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b | lines | section | |---|---| | 25-67 | Rule File Structure | -| 68-166 | Available Query Functions | -| 167-552 | Object Properties | -| 553-566 | Helper Functions | -| 567-653 | Common Patterns | -| 654-662 | Validation | +| 68-167 | Available Query Functions | +| 168-574 | Object Properties | +| 575-588 | Helper Functions | +| 589-675 | Common Patterns | +| 676-684 | Validation | # Writing Custom Starlark Lint Rules @@ -43,7 +43,7 @@ def check(): Some builtins need a deeper catalog than the default fast build: -- `refs_to`, `refs_from`, `widgets`, `xpath_expressions`, `activities_for`, +- `refs_to`, `refs_from`, `widgets`, `xpath_expressions`, `activities_for`, `flows_for`, `permissions`, `permissions_for`, `strings` and the `widget_count` field of a page or snippet need **`REFRESH CATALOG FULL`** — the `refs`, `widgets`, `xpath_expressions`, `activities`, `permissions` and `strings` tables and the @@ -88,6 +88,7 @@ Without this, a rule that reads a full-only table under a fast build gets | `rest_operations()` | list of rest_operation | Operations on consumed REST services, including their `timeout` | | `attributes_for(entity_qualified_name)` | list of attribute | Attributes for a specific entity | | `activities_for(microflow_qualified_name, nested = False)` | list of activity | Activities of a microflow, nanoflow or rule, in flow order (full catalog — auto-detected). By default only the top level: a loop is one activity and its body is left out. `nested = True` adds every object inside a loop, at any depth, right after its loop, with `parent_loop_id` and `loop_depth` set | +| `flows_for(microflow_qualified_name)` | list of flow | The sequence flows and annotation flows of a microflow, nanoflow or rule, loop bodies included (full catalog — auto-detected). `origin_id` / `destination_id` are activity `id`s from `activities_for(…, nested = True)`. Walk from an activity whose `error_handling_type` is `"Custom"` or `"CustomWithoutRollBack"` along the flow with `is_error_handler` to reach its handler; read a split's branches from `case_value` | | `permissions()` | list of permission | All permissions across all element types (full catalog — auto-detected) | | `permissions_for(entity_qualified_name)` | list of permission | Access rules for a specific entity (full catalog — auto-detected) | | `refs_to(target_name)` | list of reference | Cross-references *to* a target (full catalog — auto-detected) | @@ -517,6 +518,27 @@ def count_not(node): | `commit_type` | string | Create or change object: exactly `"Yes"`, `"YesWithoutEvents"` or `"No"`. Empty for other actions | | `with_events` | bool | True for a commit action with events, and for a create or change object with `commit_type` `"Yes"` | | `retrieve_source` | string | Retrieve: exactly `"database"` or `"association"`. For `"database"`, `entity_ref` is the retrieved entity | +| `x` | int | Canvas position: the object's stored **middle** point, as `describe` prints it in `@position(x, y)`. Inside a loop (`parent_loop_id` set) it is relative to the loop's box, so compare positions only between objects with the same `parent_loop_id` | +| `y` | int | Canvas position, middle point; grows **downwards** | +| `width` | int | The object's box width on the canvas | +| `height` | int | The object's box height on the canvas | + +### flow +One row per flow line of a microflow, nanoflow or rule, from `flows_for()`. + +| Property | Type | Example | +|----------|------|---------| +| `id` | string | Flow UUID | +| `flow_type` | string | Exactly `"SequenceFlow"` or `"AnnotationFlow"` | +| `microflow_id` | string | Parent flow document UUID | +| `microflow_qualified_name` | string | `"Sales.ACT_Customer_Create"` | +| `module_name` | string | `"Sales"` | +| `origin_id` | string | `id` of the activity the flow leaves. For an annotation flow, the annotation | +| `destination_id` | string | `id` of the activity the flow enters. For an annotation flow, the object the note is attached to | +| `is_error_handler` | bool | True for the flow out of an activity into its custom error handler (`on error … begin … end error` in `describe`) | +| `case_value` | string | A split's branch: `"true"` / `"false"`, an enumeration value or `"(empty)"`, or the entity of an inheritance split's branch, e.g. `"Sales.SpecialOrder"`. Empty for an unconditional flow | +| `origin_connection_index` | int | Side of the origin the line leaves: `0` top, `1` right, `2` bottom, `3` left | +| `destination_connection_index` | int | Side of the destination the line enters, same numbering | ### rest_client | Property | Type | Example | diff --git a/CHANGELOG.md b/CHANGELOG.md index be4fc6a04..1ea6de285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`flows_for()` and `CATALOG.FLOWS`: a flow's sequence flows in lint rules, and each activity's place on the canvas** (ako/mxcli#1299, mendixlabs/mxcli#1308) — `activities_for()` listed the objects but not which leads to which, so a rule could not tell which log belongs to which custom error handler, which activity an annotation explains, or which branch of a split is `true`. Each sequence and annotation flow is now a row with its origin and destination activity, `is_error_handler`, the branch's `case_value` and the sides it connects; activities gain `x`, `y`, `width` and `height`. Full catalog; the schema moves to version 44, so a cached catalog is rebuilt once. - **Agent prompts in full-text search** (ako/mxcli#2075) — `CATALOG.STRINGS` indexed only an agent's documentation, so `search` could not find its system or user prompt, or the description the model reads for each tool and knowledge base: the most important text in an agentic app. Each is now a row with `ObjectType` `AGENT` and a `StringContext` naming it (`system_prompt`, `user_prompt`, `agent_description`, `tool_description`, `tool_display_title`, `tool_display_description`, `knowledge_base_description`). A published MCP service's tool descriptions and titles are rows of the service too (`mcp_tool_description`, `mcp_tool_title`). Catalog schema 43. - **Agent details in the catalog: `AGENT_TOOLS`, `AGENT_KNOWLEDGE_BASES`, `AGENT_MCP_SERVICES`, `PUBLISHED_MCP_SERVICES`, and each agent's settings** (ako/mxcli#2075) — `CATALOG.AGENTS` and its siblings held a document's name only, so which agents can run a tool without the user's approval, which use a model, or which have streaming on took one `describe agent` per agent. `AGENTS` gains the usage type, model, selected model, tool choice, generation settings, streaming switches (`NULL` when not stored), context entity, variables and tool counts; `AI_MODELS` and `KNOWLEDGE_BASES` the provider and key constant; `CONSUMED_MCP_SERVICES` the protocol version, version and timeout. An agent's tools and knowledge base tools are rows of their own, and a published MCP service is a catalog object whose tool and authentication microflows are `publish` edges, so they are no longer listed in `GRAPH_DEAD_ASSETS`. Catalog schema 42. - **A widget's caption and tooltip in lint rules, and its texts tied to it in every language** (ako/mxcli#1303, mendixlabs/mxcli#1312) — with two buttons on a page a rule could not tell which caption belonged to the one without a confirmation: `widgets()` had no caption, and the `strings()` caption row named the button's inner `Forms$ClientTemplate`, not the button. `widgets()` gains `caption` and `tooltip` in the default language, and `strings()` gains `widget_id`, so a widget's caption, tooltip and confirmation texts can be collected per language. Full catalog; the schema moves to version 40, so a cached catalog is rebuilt once. diff --git a/docs-site/src/tools/catalog-tables.md b/docs-site/src/tools/catalog-tables.md index 399fd2c52..071a3654c 100644 --- a/docs-site/src/tools/catalog-tables.md +++ b/docs-site/src/tools/catalog-tables.md @@ -203,6 +203,8 @@ query written then that should keep its old result filters on | `CommitType` | Create/change object: `Yes`, `YesWithoutEvents` or `No` | | `WithEvents` | 1 for a commit with events, and a create/change with `CommitType` `Yes` | | `RetrieveSource` | `database` or `association` | +| `X`, `Y` | The object's stored middle point on the canvas, as `describe` prints it in `@position`. Y grows downwards. Inside a loop it is relative to the loop's box, not to the canvas | +| `Width`, `Height` | The object's box size | ```sql -- Database retrieves inside a loop (N+1 queries) @@ -216,6 +218,38 @@ FROM CATALOG.ACTIVITIES WHERE ActionType = 'MicroflowCallAction' AND ParentLoopId <> '' AND QueueRef = ''; ``` +### CATALOG.FLOWS + +One row per sequence flow and annotation flow of a microflow, nanoflow or rule, +loop bodies included. Populated by `REFRESH CATALOG FULL`. Both ends are +`CATALOG.ACTIVITIES.Id` values. + +| Column | Description | +|--------|-------------| +| `Id` | The flow's ID | +| `FlowType` | `SequenceFlow` or `AnnotationFlow` | +| `MicroflowId`, `MicroflowQualifiedName`, `ModuleName` | The flow document it belongs to | +| `Sequence` | Stored order: each collection's sequence flows, then its annotation flows | +| `OriginId`, `DestinationId` | The activity the line leaves and enters. An annotation flow runs from the annotation to the object it is attached to | +| `IsErrorHandler` | 1 for the flow into an activity's custom error handler (`on error … begin … end error` in `describe`) | +| `CaseValue` | The branch a split's flow takes: `true` / `false`, an enumeration value or `(empty)`, or an inheritance split's entity. Empty for an unconditional flow | +| `OriginConnectionIndex`, `DestinationConnectionIndex` | The side the line leaves and enters: 0 top, 1 right, 2 bottom, 3 left | + +```sql +-- Custom error handlers whose first activity logs below level Error +SELECT f.MicroflowQualifiedName, a.LogLevel +FROM CATALOG.FLOWS f +JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId +WHERE f.IsErrorHandler = 1 AND a.ActionType = 'LogMessageAction' AND a.LogLevel <> 'Error'; + +-- The activity each annotation is attached to +SELECT f.MicroflowQualifiedName, n.Caption, a.ActionType +FROM CATALOG.FLOWS f +JOIN CATALOG.ACTIVITIES n ON n.Id = f.OriginId +JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId +WHERE f.FlowType = 'AnnotationFlow'; +``` + ### CATALOG.PAGES Information about pages and their properties. diff --git a/docs-site/src/tools/custom-rules.md b/docs-site/src/tools/custom-rules.md index 12889fbe0..1fb7825fe 100644 --- a/docs-site/src/tools/custom-rules.md +++ b/docs-site/src/tools/custom-rules.md @@ -50,7 +50,7 @@ skill lists them all. `violation(...)` and `location(...)` build the result. ## Catalog depth Some builtins read data that only a full catalog build produces (`widgets()`, -`xpath_expressions()`, `activities_for()`, `permissions()`, +`xpath_expressions()`, `activities_for()`, `flows_for()`, `permissions()`, `permissions_for()`, `refs_to()` / `refs_from()`, a page's `widget_count`), and the graph builtins (`cycles()`, `layer_of()`, …) need the communities build. `mxcli lint` detects them in the rule's source and builds the catalog deep diff --git a/mdl-examples/bug-tests/1299-sequence-flows.mdl b/mdl-examples/bug-tests/1299-sequence-flows.mdl new file mode 100644 index 000000000..3a86ac8c7 --- /dev/null +++ b/mdl-examples/bug-tests/1299-sequence-flows.mdl @@ -0,0 +1,34 @@ +mdl 1; +-- ako/mxcli#1299 (mendixlabs/mxcli#1308): a Starlark rule could not see the +-- sequence flows of a microflow, so it could not tell which log belongs to +-- which custom error handler, which activity an annotation is attached to, or +-- which branch of a split is true; activities had no canvas position. +-- +-- Run on a fresh app, then: +-- mxcli -p App.mpr -c "refresh catalog full force" +-- mxcli -p App.mpr -c "SELECT a.LogLevel FROM CATALOG.FLOWS f JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId WHERE f.MicroflowQualifiedName = 'Bug1299.Act_Store' AND f.IsErrorHandler = 1 ORDER BY a.LogLevel" +-- Expected: Error, Warning (one row per handler). +-- mxcli -p App.mpr -c "SELECT a.ActionType FROM CATALOG.FLOWS f JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId WHERE f.MicroflowQualifiedName = 'Bug1299.Act_Store' AND f.FlowType = 'AnnotationFlow'" +-- Expected: ChangeObjectAction. +-- mxcli -p App.mpr -c "SELECT CaseValue FROM CATALOG.FLOWS WHERE MicroflowQualifiedName = 'Bug1299.Act_Store' AND CaseValue <> '' ORDER BY CaseValue" +-- Expected: false, true. +-- Before the fix CATALOG.FLOWS did not exist, and a rule calling flows_for() +-- was skipped as "undefined: flows_for". + +create module Bug1299; + +create persistent entity Bug1299.Sale (Amount: Integer); + +create microflow Bug1299.Act_Store ($Sale: Bug1299.Sale) +begin + @annotation 'Amount is reset before storing' + change $Sale (Amount = 0); + commit $Sale without events on error without rollback begin + log warning node 'Bug1299' 'First commit failed'; + end error; + if $Sale/Amount > 10 then + commit $Sale with events on error without rollback begin + log error node 'Bug1299' 'Second commit failed'; + end error; + end if; +end; diff --git a/mdl/catalog/builder_flows_test.go b/mdl/catalog/builder_flows_test.go new file mode 100644 index 000000000..bf81b9ea3 --- /dev/null +++ b/mdl/catalog/builder_flows_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1308 (ako/mxcli#1299): a Starlark rule could not see the +// sequence flows of a microflow — which object leads to which, which flow is +// an error handler, which branch of a split is `true`, which activity an +// annotation is attached to — nor where an object sits on the canvas. The +// reader decodes all of it (describe prints `on error`, `@annotation` and +// `@position` from it); the catalog wrote none of it. + +func placed[T microflows.MicroflowObject](id string, x, y, w, h int, o T) T { + o = loopObj(id, o) + o.SetPosition(model.Point{X: x, Y: y}) + if s, ok := any(o).(interface{ SetSize(model.Size) }); ok { + s.SetSize(model.Size{Width: w, Height: h}) + } + return o +} + +func seqFlow(id, from, to string, cv microflows.CaseValue, errHandler bool, oi, di int) *microflows.SequenceFlow { + f := µflows.SequenceFlow{ + OriginID: model.ID(from), DestinationID: model.ID(to), + OriginConnectionIndex: oi, DestinationConnectionIndex: di, + CaseValue: cv, IsErrorHandler: errHandler, + } + f.ID = model.ID(id) + return f +} + +// flowsFixture is the issue's microflow, plus an enumeration split, an +// inheritance split and a loop whose body flows sit in the loop's own +// collection (older models; 11.12 keeps them on the microflow): +// +// start -> @annotation change -> commit (on error -> log warning) +// -> if (true -> commit2, false -> merge) -> case (Open, (empty)) +// -> loop { change2 -> commit3 } -> end +func flowsFixture() *microflows.MicroflowObjectCollection { + loop := placed("loop", 1500, 200, 300, 150, µflows.LoopedActivity{ + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + placed("change2", 1450, 200, 120, 60, µflows.ActionActivity{Action: µflows.ChangeObjectAction{}}), + placed("commit3", 1550, 200, 120, 60, µflows.ActionActivity{Action: µflows.CommitObjectsAction{}}), + }, + Flows: []*microflows.SequenceFlow{seqFlow("f-loop", "change2", "commit3", nil, false, 1, 3)}, + }, + }) + note := µflows.AnnotationFlow{OriginID: "note", DestinationID: "change", Sides: µflows.ConnectionSides{Origin: 2, Destination: 0}} + note.ID = "af-note" + return µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + placed("start", 200, 200, 20, 20, µflows.StartEvent{}), + placed("note", 360, 100, 200, 50, µflows.Annotation{Caption: "Amount is reset before storing"}), + placed("change", 360, 200, 120, 60, µflows.ActionActivity{Action: µflows.ChangeObjectAction{}}), + placed("commit", 500, 200, 120, 60, µflows.ActionActivity{Action: µflows.CommitObjectsAction{}}), + placed("log", 500, 330, 120, 60, µflows.ActionActivity{Action: µflows.LogMessageAction{LogLevel: "Warning"}}), + placed("split", 700, 200, 120, 60, µflows.ExclusiveSplit{}), + placed("commit2", 700, 330, 120, 60, µflows.ActionActivity{Action: µflows.CommitObjectsAction{}}), + placed("merge", 900, 200, 30, 30, µflows.ExclusiveMerge{}), + placed("enum", 1100, 200, 120, 60, µflows.ExclusiveSplit{}), + placed("isplit", 1300, 200, 120, 60, µflows.InheritanceSplit{}), + loop, + placed("end", 1800, 200, 20, 20, µflows.EndEvent{}), + }, + Flows: []*microflows.SequenceFlow{ + seqFlow("f1", "start", "change", µflows.NoCase{}, false, 1, 3), + seqFlow("f2", "change", "commit", nil, false, 1, 3), + seqFlow("f3", "commit", "log", nil, true, 2, 0), + seqFlow("f4", "commit", "split", nil, false, 1, 3), + seqFlow("f5", "split", "commit2", µflows.EnumerationCase{Value: "true"}, false, 2, 0), + seqFlow("f6", "split", "merge", µflows.EnumerationCase{Value: "false"}, false, 1, 3), + seqFlow("f7", "merge", "enum", nil, false, 1, 3), + seqFlow("f8", "enum", "isplit", µflows.EnumerationCase{Value: "Open"}, false, 1, 3), + seqFlow("f9", "enum", "isplit", µflows.EnumerationCase{Value: "(empty)"}, false, 2, 0), + seqFlow("f10", "isplit", "loop", µflows.InheritanceCase{EntityQualifiedName: "Loops.Special"}, false, 1, 3), + seqFlow("f11", "loop", "end", µflows.BooleanCase{Value: false}, false, 1, 3), + }, + AnnotationFlows: []*microflows.AnnotationFlow{note}, + } +} + +func flowRowsOf(t *testing.T, cat *Catalog, flow string) []string { + t.Helper() + res, err := cat.Query(`SELECT Id, FlowType, OriginId, DestinationId, IsErrorHandler, CaseValue, + OriginConnectionIndex, DestinationConnectionIndex, MicroflowId, ModuleName + FROM flows WHERE MicroflowQualifiedName = '` + flow + `' ORDER BY Sequence`) + if err != nil { + t.Fatalf("query flows: %v", err) + } + var out []string + for _, r := range res.Rows { + out = append(out, fmt.Sprintf("%v|%v|%v|%v|%v|%v|%v|%v|%v|%v", r...)) + } + return out +} + +func assertFlowRows(t *testing.T, cat *Catalog, flow, flowID string) { + t.Helper() + sf := func(id, from, to string, eh int, cv string, oi, di int) string { + return fmt.Sprintf("%s|SequenceFlow|%s|%s|%d|%s|%d|%d|%s|Loops", id, from, to, eh, cv, oi, di, flowID) + } + want := []string{ + sf("f1", "start", "change", 0, "", 1, 3), + sf("f2", "change", "commit", 0, "", 1, 3), + sf("f3", "commit", "log", 1, "", 2, 0), + sf("f4", "commit", "split", 0, "", 1, 3), + sf("f5", "split", "commit2", 0, "true", 2, 0), + sf("f6", "split", "merge", 0, "false", 1, 3), + sf("f7", "merge", "enum", 0, "", 1, 3), + sf("f8", "enum", "isplit", 0, "Open", 1, 3), + sf("f9", "enum", "isplit", 0, "(empty)", 2, 0), + sf("f10", "isplit", "loop", 0, "Loops.Special", 1, 3), + sf("f11", "loop", "end", 0, "false", 1, 3), + "af-note|AnnotationFlow|note|change|0||2|0|" + flowID + "|Loops", + sf("f-loop", "change2", "commit3", 0, "", 1, 3), + } + got := flowRowsOf(t, cat, flow) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("%s flows:\n%s\nwant:\n%s", flow, strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestFlowsTable_Issue1299(t *testing.T) { + mf := µflows.Microflow{ContainerID: loopTestModule, Name: "Act_Store", ObjectCollection: flowsFixture()} + mf.ID = "mf-store" + nf := µflows.Nanoflow{ContainerID: loopTestModule, Name: "NF_Store", ObjectCollection: flowsFixture()} + nf.ID = "nf-store" + rule := µflows.Rule{ContainerID: loopTestModule, Name: "R_Store", ObjectCollection: flowsFixture()} + rule.ID = "rule-store" + // The three share the fixture's ids, so each is built on its own catalog. + assertFlowRows(t, buildFlowsForTest(t, []*microflows.Microflow{mf}, nil, nil), "Loops.Act_Store", "mf-store") + assertFlowRows(t, buildFlowsForTest(t, nil, []*microflows.Nanoflow{nf}, nil), "Loops.NF_Store", "nf-store") + assertFlowRows(t, buildFlowsForTest(t, nil, nil, []*microflows.Rule{rule}), "Loops.R_Store", "rule-store") +} + +// Each activity's canvas box: X / Y are the stored middle point (what +// describe prints as @position), Width / Height its size. +func TestActivityPosition_Issue1299(t *testing.T) { + mf := µflows.Microflow{ContainerID: loopTestModule, Name: "Act_Store", ObjectCollection: flowsFixture()} + mf.ID = "mf-store" + cat := buildFlowsForTest(t, []*microflows.Microflow{mf}, nil, nil) + res, err := cat.Query(`SELECT Id, X, Y, Width, Height FROM activities + WHERE Id IN ('start', 'change', 'note', 'merge', 'commit3') ORDER BY Sequence`) + if err != nil { + t.Fatalf("query activities: %v", err) + } + var got []string + for _, r := range res.Rows { + got = append(got, fmt.Sprintf("%v|%v|%v|%v|%v", r...)) + } + want := []string{ + "start|200|200|20|20", + "note|360|100|200|50", + "change|360|200|120|60", + "merge|900|200|30|30", + "commit3|1550|200|120|60", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("activity boxes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} diff --git a/mdl/catalog/builder_microflows.go b/mdl/catalog/builder_microflows.go index e4baa06db..f1f3ac9a7 100644 --- a/mdl/catalog/builder_microflows.go +++ b/mdl/catalog/builder_microflows.go @@ -69,8 +69,8 @@ func (b *Builder) buildMicroflows() error { } defer paramStmt.Close() - // Prepare activity statement only in full mode - var actStmt *Stmt + // Prepare the activity and flow statements only in full mode + var actStmt, flowStmt *Stmt if b.fullMode { actStmt, err = b.tx.Prepare(` INSERT INTO activities_data (Id, Name, Caption, ActivityType, Sequence, MicroflowId, MicroflowQualifiedName, @@ -79,14 +79,24 @@ func (b *Builder) buildMicroflows() error { ParentLoopId, LoopDepth, AutoGenerateCaption, ConditionExpression, ConditionRule, ErrorHandlingType, LogLevel, LogNodeExpression, LogMessage, CommitType, WithEvents, RetrieveSource, - QueueRef, ProjectId, SnapshotId) + QueueRef, X, Y, Width, Height, ProjectId, SnapshotId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err } defer actStmt.Close() + flowStmt, err = b.tx.Prepare(` + INSERT INTO flows_data (Id, FlowType, Sequence, MicroflowId, MicroflowQualifiedName, ModuleName, + OriginId, DestinationId, IsErrorHandler, CaseValue, + OriginConnectionIndex, DestinationConnectionIndex, ProjectId, SnapshotId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer flowStmt.Close() } // A delete names a variable, not an entity; its EntityRef is resolved the @@ -189,6 +199,9 @@ func (b *Builder) buildMicroflows() error { return err } actCount += n + if err := insertSequenceFlows(flowStmt, string(mf.ID), qualifiedName, moduleName, mf.ObjectCollection, projectID, snapshotID); err != nil { + return err + } } } b.disown() @@ -247,6 +260,9 @@ func (b *Builder) buildMicroflows() error { return err } actCount += n + if err := insertSequenceFlows(flowStmt, string(nf.ID), qualifiedName, moduleName, nf.ObjectCollection, projectID, snapshotID); err != nil { + return err + } } } b.disown() @@ -301,6 +317,9 @@ func (b *Builder) buildMicroflows() error { return err } actCount += n + if err := insertSequenceFlows(flowStmt, string(rule.ID), qualifiedName, moduleName, rule.ObjectCollection, projectID, snapshotID); err != nil { + return err + } } } b.disown() @@ -556,6 +575,11 @@ func insertFlowActivities(stmt *Stmt, flowID, qualifiedName, moduleName string, } seq++ r := describeFlowObject(obj, varEntity) + pos := obj.GetPosition() + var size model.Size + if sized, ok := obj.(interface{ GetSize() model.Size }); ok { + size = sized.GetSize() + } if _, err := stmt.Exec( string(obj.GetID()), r.name, @@ -586,6 +610,7 @@ func insertFlowActivities(stmt *Stmt, flowID, qualifiedName, moduleName string, boolInt(r.withEvents), r.retrieveSource, r.queueRef, + pos.X, pos.Y, size.Width, size.Height, projectID, snapshotID, ); err != nil { return err @@ -604,6 +629,86 @@ func insertFlowActivities(stmt *Stmt, flowID, qualifiedName, moduleName string, return seq, nil } +// Values of flows_data.FlowType. +const ( + FlowTypeSequence = "SequenceFlow" + FlowTypeAnnotation = "AnnotationFlow" +) + +// insertSequenceFlows writes one flows_data row per sequence flow and per +// annotation flow of a flow body (ako/mxcli#1299): which object leads to +// which, whether it is an error handler flow, and the branch a split's flow +// takes. Origin and destination are the activities_data Ids. +// +// Mendix 11 keeps every flow on the document, loop bodies' included; a loop's +// own collection is walked too, for a model that keeps them there. Sequence +// numbers the rows in stored order, sequence flows before annotation flows of +// the same collection. +func insertSequenceFlows(stmt *Stmt, flowID, qualifiedName, moduleName string, + oc *microflows.MicroflowObjectCollection, projectID, snapshotID string) error { + if stmt == nil || oc == nil { + return nil + } + seq := 0 + var walk func(oc *microflows.MicroflowObjectCollection) error + walk = func(oc *microflows.MicroflowObjectCollection) error { + for _, f := range oc.Flows { + if f == nil { + continue + } + seq++ + if _, err := stmt.Exec(string(f.ID), FlowTypeSequence, seq, flowID, qualifiedName, moduleName, + string(f.OriginID), string(f.DestinationID), boolInt(f.IsErrorHandler), caseValueText(f.CaseValue), + f.OriginConnectionIndex, f.DestinationConnectionIndex, projectID, snapshotID); err != nil { + return err + } + } + for _, f := range oc.AnnotationFlows { + if f == nil { + continue + } + seq++ + sides := f.SidesOrDefault() + if _, err := stmt.Exec(string(f.ID), FlowTypeAnnotation, seq, flowID, qualifiedName, moduleName, + string(f.OriginID), string(f.DestinationID), 0, "", + sides.Origin, sides.Destination, projectID, snapshotID); err != nil { + return err + } + } + for _, obj := range oc.Objects { + if loop, ok := obj.(*microflows.LoopedActivity); ok && loop.ObjectCollection != nil { + if err := walk(loop.ObjectCollection); err != nil { + return err + } + } + } + return nil + } + return walk(oc) +} + +// caseValueText is the branch a split's outgoing flow takes, as describe +// spells it: "true" / "false" (stored as an enumeration case), an enumeration +// value or "(empty)", an inheritance split's entity, or "" for none. +func caseValueText(cv microflows.CaseValue) string { + switch c := cv.(type) { + case *microflows.EnumerationCase: + return c.Value + case microflows.EnumerationCase: + return c.Value + case *microflows.BooleanCase: + if c.Value { + return "true" + } + return "false" + case *microflows.ExpressionCase: + return c.Expression + case *microflows.InheritanceCase: + return c.EntityQualifiedName + } + return "" +} + // Values of activities_data.RetrieveSource — where a retrieve reads from. // Lower-case, as mendixlabs/mxcli#1267 agreed. Named because a rule filters on // them: a literal that disagrees with the writer matches nothing, silently. diff --git a/mdl/catalog/catalog.go b/mdl/catalog/catalog.go index f6ba63d1f..47ee873f4 100644 --- a/mdl/catalog/catalog.go +++ b/mdl/catalog/catalog.go @@ -144,6 +144,7 @@ func (c *Catalog) Tables() []string { "CATALOG.OFFLINE_ENTITY_CONFIGS", "CATALOG.ENTITY_EVENT_HANDLERS", "CATALOG.ACTIVITIES", + "CATALOG.FLOWS", "CATALOG.WIDGETS", "CATALOG.WIDGET_DEFINITIONS", "CATALOG.WIDGET_DEFINITION_PROPERTIES", diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 304cb3964..e85b96cc4 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,11 @@ package catalog // // History: // +// 44 (sequence flows): new flows table, one row per sequence and +// annotation flow of a microflow, nanoflow or rule; activities gains +// X, Y, Width and Height, the object's canvas box (ako/mxcli#1299). +// Without the bump a cached catalog lacks the table and the columns, +// and flows_for() and activities_for() fail with "no such column". // 43 (agent prompts): strings gains an agent's description, system and // user prompts, and its tool and knowledge base descriptions // (ako/mxcli#2075). Without the bump a cached catalog keeps answering @@ -235,7 +240,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "43" +const CatalogSchemaVersion = "44" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -906,11 +911,41 @@ func (c *Catalog) createTables() error { WithEvents INTEGER DEFAULT 0, RetrieveSource TEXT DEFAULT '', QueueRef TEXT DEFAULT '', + -- The canvas box: X / Y the stored middle point (describe's + -- @position), Width / Height its size. + X INTEGER DEFAULT 0, + Y INTEGER DEFAULT 0, + Width INTEGER DEFAULT 0, + Height INTEGER DEFAULT 0, ProjectId TEXT, SnapshotId TEXT )`, viewWithFullSnapshot("activities"), + // flows — one row per sequence flow and annotation flow of a flow + // body (ako/mxcli#1299). OriginId / DestinationId are activities.Id. + // An annotation flow runs from the note to the object it annotates. + // CaseValue is the branch a split's outgoing flow takes: "true" / + // "false", an enumeration value or "(empty)", an inheritance split's + // entity; "" for an unconditional flow. + `CREATE TABLE IF NOT EXISTS flows_data ( + Id TEXT PRIMARY KEY, + FlowType TEXT, + Sequence INTEGER DEFAULT 0, + MicroflowId TEXT, + MicroflowQualifiedName TEXT, + ModuleName TEXT, + OriginId TEXT, + DestinationId TEXT, + IsErrorHandler INTEGER DEFAULT 0, + CaseValue TEXT DEFAULT '', + OriginConnectionIndex INTEGER DEFAULT 0, + DestinationConnectionIndex INTEGER DEFAULT 0, + ProjectId TEXT, + SnapshotId TEXT + )`, + viewWithFullSnapshot("flows"), + // widget_definitions — widgets AVAILABLE in the project (.mpk / .def.json) `CREATE TABLE IF NOT EXISTS widget_definitions_data ( WidgetId TEXT PRIMARY KEY, @@ -1784,6 +1819,7 @@ func (c *Catalog) createTables() error { `CREATE INDEX IF NOT EXISTS idx_layouts_module ON layouts_data(ModuleName)`, `CREATE INDEX IF NOT EXISTS idx_activities_microflow ON activities_data(MicroflowId)`, `CREATE INDEX IF NOT EXISTS idx_activities_type ON activities_data(ActivityType)`, + `CREATE INDEX IF NOT EXISTS idx_flows_microflow ON flows_data(MicroflowQualifiedName)`, `CREATE INDEX IF NOT EXISTS idx_widgets_container ON widgets_data(ContainerId)`, `CREATE INDEX IF NOT EXISTS idx_widgets_type ON widgets_data(WidgetType)`, `CREATE INDEX IF NOT EXISTS idx_widgets_parent ON widgets_data(ParentWidgetId)`, diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index d6b04cf1f..522e79a05 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -80,6 +80,7 @@ func execShowCatalogTables(ctx *ExecContext) error { var fullOnlyTables = map[string]bool{ "widgets": true, "activities": true, + "flows": true, "refs": true, "xpath_expressions": true, "strings": true, diff --git a/mdl/linter/context.go b/mdl/linter/context.go index abe8a1504..d2b7259a7 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -1572,6 +1572,10 @@ type Activity struct { // asynchronously, so a loop-scoped rule should not follow it // (mendixlabs/mxcli#1305). QueueRef string + // X / Y is the object's stored middle point on the canvas (describe's + // @position) and Width / Height its size (ako/mxcli#1299). Y grows + // downwards. + X, Y, Width, Height int } // ActivitiesFor returns an iterator over the top-level activities of a flow — @@ -1601,7 +1605,8 @@ func (ctx *LintContext) ActivitiesIn(microflowQualifiedName string, nested bool) COALESCE(ErrorHandlingType, ''), COALESCE(LogLevel, ''), COALESCE(LogNodeExpression, ''), COALESCE(LogMessage, ''), COALESCE(CommitType, ''), COALESCE(WithEvents, 0), COALESCE(RetrieveSource, ''), - COALESCE(QueueRef, '') + COALESCE(QueueRef, ''), + COALESCE(X, 0), COALESCE(Y, 0), COALESCE(Width, 0), COALESCE(Height, 0) FROM activities WHERE MicroflowQualifiedName = ? AND (? OR COALESCE(ParentLoopId, '') = '') @@ -1628,7 +1633,8 @@ func (ctx *LintContext) ActivitiesIn(microflowQualifiedName string, nested bool) &a.ErrorHandlingType, &a.LogLevel, &a.LogNodeExpression, &a.LogMessage, &a.CommitType, &withEvents, &a.RetrieveSource, - &a.QueueRef) + &a.QueueRef, + &a.X, &a.Y, &a.Width, &a.Height) if err != nil { ctx.recordQueryError("ActivitiesFor (row scan)", err) continue @@ -1650,6 +1656,60 @@ func (ctx *LintContext) ActivitiesIn(microflowQualifiedName string, nested bool) } } +// Flow is one sequence flow or annotation flow of a microflow, nanoflow or +// rule (ako/mxcli#1299). OriginID and DestinationID are Activity.ID values, +// loop bodies included; an annotation flow runs from the note to the object +// it annotates. CaseValue is the branch a split's flow takes ("true" / +// "false", an enumeration value or "(empty)", an inheritance split's entity), +// "" for an unconditional flow. +type Flow struct { + ID string + FlowType string + MicroflowID string + MicroflowQualifiedName string + ModuleName string + OriginID string + DestinationID string + IsErrorHandler bool + CaseValue string + OriginConnectionIndex int + DestinationConnectionIndex int +} + +// FlowsFor returns the flows of a microflow, nanoflow or rule in stored +// order, the sequence flows of each collection before its annotation flows. +func (ctx *LintContext) FlowsFor(microflowQualifiedName string) iter.Seq[Flow] { + return func(yield func(Flow) bool) { + rows, err := ctx.db.Query(` + SELECT Id, FlowType, MicroflowId, MicroflowQualifiedName, ModuleName, + OriginId, DestinationId, COALESCE(IsErrorHandler, 0), COALESCE(CaseValue, ''), + COALESCE(OriginConnectionIndex, 0), COALESCE(DestinationConnectionIndex, 0) + FROM flows + WHERE MicroflowQualifiedName = ? + ORDER BY Sequence + `, microflowQualifiedName) + if err != nil { + ctx.recordQueryError("FlowsFor", err) + return + } + defer rows.Close() + for rows.Next() { + var f Flow + var errHandler int + if err := rows.Scan(&f.ID, &f.FlowType, &f.MicroflowID, &f.MicroflowQualifiedName, &f.ModuleName, + &f.OriginID, &f.DestinationID, &errHandler, &f.CaseValue, + &f.OriginConnectionIndex, &f.DestinationConnectionIndex); err != nil { + ctx.recordQueryError("FlowsFor (row scan)", err) + continue + } + f.IsErrorHandler = errHandler != 0 + if !yield(f) { + return + } + } + } +} + // Reference represents a reference from the refs table. type Reference struct { SourceType string diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index 78511e851..920599b9c 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -226,7 +226,7 @@ var communityBuiltins = []string{ // any builtin whose answer differs and is not listed here. var fullBuiltins = []string{ "refs_to", "refs_from", - "widgets", "xpath_expressions", "activities_for", + "widgets", "xpath_expressions", "activities_for", "flows_for", "permissions", "permissions_for", "strings", } @@ -448,6 +448,7 @@ func (r *StarlarkRule) buildPredeclared() starlark.StringDict { "rest_clients": starlark.NewBuiltin("rest_clients", r.builtinRestClients), "rest_operations": starlark.NewBuiltin("rest_operations", r.builtinRestOperations), "activities_for": starlark.NewBuiltin("activities_for", r.builtinActivitiesFor), + "flows_for": starlark.NewBuiltin("flows_for", r.builtinFlowsFor), // Project-level queries "user_roles": starlark.NewBuiltin("user_roles", r.builtinUserRoles), @@ -1035,6 +1036,25 @@ func (r *StarlarkRule) builtinActivitiesFor(_ *starlark.Thread, _ *starlark.Buil return starlark.NewList(activities), nil } +// builtinFlowsFor returns the sequence and annotation flows of a microflow, +// nanoflow or rule (ako/mxcli#1299), loop bodies included. +func (r *StarlarkRule) builtinFlowsFor(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if r.ctx == nil { + return starlark.NewList(nil), nil + } + var microflowQualifiedName starlark.String + if err := starlark.UnpackArgs("flows_for", args, kwargs, + "microflow_qualified_name", µflowQualifiedName, + ); err != nil { + return nil, err + } + var flows []starlark.Value + for f := range r.ctx.FlowsFor(string(microflowQualifiedName)) { + flows = append(flows, flowToStarlark(f)) + } + return starlark.NewList(flows), nil +} + // builtinUserRoles returns all user roles from project security. func (r *StarlarkRule) builtinUserRoles(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { if r.ctx == nil { @@ -1485,6 +1505,27 @@ func activityToStarlark(a Activity) starlark.Value { "with_events": starlark.Bool(a.WithEvents), "retrieve_source": starlark.String(a.RetrieveSource), "queue_ref": starlark.String(a.QueueRef), + "x": starlark.MakeInt(a.X), + "y": starlark.MakeInt(a.Y), + "width": starlark.MakeInt(a.Width), + "height": starlark.MakeInt(a.Height), + }) +} + +// flowToStarlark converts a Flow to a Starlark struct. +func flowToStarlark(f Flow) starlark.Value { + return starlarkstruct.FromStringDict(starlark.String("flow"), starlark.StringDict{ + "id": starlark.String(f.ID), + "flow_type": starlark.String(f.FlowType), + "microflow_id": starlark.String(f.MicroflowID), + "microflow_qualified_name": starlark.String(f.MicroflowQualifiedName), + "module_name": starlark.String(f.ModuleName), + "origin_id": starlark.String(f.OriginID), + "destination_id": starlark.String(f.DestinationID), + "is_error_handler": starlark.Bool(f.IsErrorHandler), + "case_value": starlark.String(f.CaseValue), + "origin_connection_index": starlark.MakeInt(f.OriginConnectionIndex), + "destination_connection_index": starlark.MakeInt(f.DestinationConnectionIndex), }) } diff --git a/mdl/linter/starlark_catalog_mode_guard_test.go b/mdl/linter/starlark_catalog_mode_guard_test.go index 9e6a7be1e..84e9c82a6 100644 --- a/mdl/linter/starlark_catalog_mode_guard_test.go +++ b/mdl/linter/starlark_catalog_mode_guard_test.go @@ -74,6 +74,7 @@ var builtinModes = map[string]builtinProbe{ "widgets": {CatalogFull, "widgets()"}, "xpath_expressions": {CatalogFull, "xpath_expressions()"}, "activities_for": {CatalogFull, "[a for n in FLOW_NAMES for a in activities_for(n)]"}, + "flows_for": {CatalogFull, "[f for n in FLOW_NAMES for f in flows_for(n)]"}, "permissions": {CatalogFull, "permissions()"}, "permissions_for": {CatalogFull, "[p for n in ENTITY_NAMES for p in permissions_for(n)]"}, "refs_to": {CatalogFull, "[r for n in REF_NAMES for r in refs_to(n)]"}, diff --git a/mdl/linter/starlark_flow_noun_test.go b/mdl/linter/starlark_flow_noun_test.go index ba887f41d..399c6f07a 100644 --- a/mdl/linter/starlark_flow_noun_test.go +++ b/mdl/linter/starlark_flow_noun_test.go @@ -132,7 +132,7 @@ func flowKindsFixtureDB(t *testing.T) catalog.CatalogDB { Description TEXT, AutoGenerateCaption INTEGER, ConditionExpression TEXT, ConditionRule TEXT, ErrorHandlingType TEXT, LogLevel TEXT, LogNodeExpression TEXT, LogMessage TEXT, CommitType TEXT, WithEvents INTEGER, RetrieveSource TEXT, - QueueRef TEXT)`, + QueueRef TEXT, X INTEGER, Y INTEGER, Width INTEGER, Height INTEGER)`, `INSERT INTO activities (Id, Name, Caption, ActivityType, ActionType, MicroflowId, MicroflowQualifiedName, ModuleName, EntityRef, ServiceRef, ActionRef, UseRequestTimeout, TimeoutExpression, Sequence, ParentLoopId, LoopDepth) VALUES diff --git a/mdl/linter/starlark_flows_for_test.go b/mdl/linter/starlark_flows_for_test.go new file mode 100644 index 000000000..49e277b60 --- /dev/null +++ b/mdl/linter/starlark_flows_for_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// ako/mxcli#1299 (mendixlabs/mxcli#1308): the issue's three checks, written +// against flows_for() and the activity box. With only activities_for(), a +// rule could not tell which log belongs to which error handler, which +// activity an annotation is attached to, or which way a split's branches go. +func TestFlowsFor_Issue1299(t *testing.T) { + cat, err := catalog.New() + if err != nil { + t.Fatal(err) + } + defer cat.Close() + db := cat.CatalogDB() + for _, q := range []string{ + `INSERT INTO microflows_data (Id, Name, QualifiedName, ModuleName, MicroflowType, ActivityCount, TotalActivityCount) + VALUES ('mf', 'Act_Store', 'M.Act_Store', 'M', 'MICROFLOW', 8, 8)`, + `INSERT INTO activities_data (Id, ActivityType, ActionType, Sequence, MicroflowId, MicroflowQualifiedName, + ModuleName, ErrorHandlingType, LogLevel, WithEvents, Caption, X, Y, Width, Height) + VALUES + ('note', 'Annotation', '', 1, 'mf', 'M.Act_Store', 'M', '', '', 0, 'Reset first', 360, 100, 200, 50), + ('change', 'ActionActivity', 'ChangeObjectAction', 2, 'mf', 'M.Act_Store', 'M', 'Rollback', '', 0, '', 360, 200, 120, 60), + ('commit1', 'ActionActivity', 'CommitObjectsAction', 3, 'mf', 'M.Act_Store', 'M', 'CustomWithoutRollBack', '', 0, '', 500, 200, 120, 60), + ('log1', 'ActionActivity', 'LogMessageAction', 4, 'mf', 'M.Act_Store', 'M', 'Rollback', 'Warning', 0, '', 500, 330, 120, 60), + ('split', 'ExclusiveSplit', '', 5, 'mf', 'M.Act_Store', 'M', 'Rollback', '', 0, '', 700, 200, 120, 60), + ('commit2', 'ActionActivity', 'CommitObjectsAction', 6, 'mf', 'M.Act_Store', 'M', 'CustomWithoutRollBack', '', 1, '', 700, 330, 120, 60), + ('log2', 'ActionActivity', 'LogMessageAction', 7, 'mf', 'M.Act_Store', 'M', 'Rollback', 'Error', 0, '', 700, 460, 120, 60), + ('merge', 'ExclusiveMerge', '', 8, 'mf', 'M.Act_Store', 'M', '', '', 0, '', 900, 200, 30, 30)`, + `INSERT INTO flows_data (Id, FlowType, Sequence, MicroflowId, MicroflowQualifiedName, ModuleName, + OriginId, DestinationId, IsErrorHandler, CaseValue, OriginConnectionIndex, DestinationConnectionIndex) + VALUES + ('f1', 'SequenceFlow', 1, 'mf', 'M.Act_Store', 'M', 'change', 'commit1', 0, '', 1, 3), + ('f2', 'SequenceFlow', 2, 'mf', 'M.Act_Store', 'M', 'commit1', 'log1', 1, '', 2, 0), + ('f3', 'SequenceFlow', 3, 'mf', 'M.Act_Store', 'M', 'commit1', 'split', 0, '', 1, 3), + ('f4', 'SequenceFlow', 4, 'mf', 'M.Act_Store', 'M', 'split', 'commit2', 0, 'true', 2, 0), + ('f5', 'SequenceFlow', 5, 'mf', 'M.Act_Store', 'M', 'split', 'merge', 0, 'false', 1, 3), + ('f6', 'SequenceFlow', 6, 'mf', 'M.Act_Store', 'M', 'commit2', 'log2', 1, '', 2, 0), + ('a1', 'AnnotationFlow', 7, 'mf', 'M.Act_Store', 'M', 'note', 'change', 0, '', 2, 0)`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatalf("fixture: %v", err) + } + } + + rule := `RULE_ID = "TEST01" +RULE_NAME = "Flows" +DESCRIPTION = "probe" +CATEGORY = "quality" +SEVERITY = "info" +REQUIRES = ["full"] + +def check(): + out = [] + for mf in microflows(): + acts = {a.id: a for a in activities_for(mf.qualified_name, nested = True)} + flows = flows_for(mf.qualified_name) + for f in flows: + if f.is_error_handler: + handler = acts[f.destination_id] + if handler.action_type == "LogMessageAction" and handler.log_level != "Error": + out.append(violation(message = "handler of %s logs at %s" % (f.origin_id, handler.log_level))) + if f.flow_type == "AnnotationFlow": + out.append(violation(message = "%r annotates %s" % (acts[f.origin_id].caption, acts[f.destination_id].action_type))) + if f.case_value in ("true", "false"): + o, d = acts[f.origin_id], acts[f.destination_id] + way = "down" if d.y > o.y else "right" if d.x > o.x else "other" + out.append(violation(message = "%s branch goes %s" % (f.case_value, way))) + return out +` + path := filepath.Join(t.TempDir(), "flows.star") + if err := os.WriteFile(path, []byte(rule), 0o644); err != nil { + t.Fatal(err) + } + r, err := linter.LoadStarlarkRule(path) + if err != nil { + t.Fatalf("LoadStarlarkRule: %v", err) + } + ctx := linter.NewLintContextFromDB(db) + var got []string + for _, v := range r.Check(ctx) { + got = append(got, v.Message) + } + if errs := ctx.QueryErrors(); len(errs) > 0 { + t.Fatalf("query errors: %v -- the query names a column the catalog does not have", errs) + } + sort.Strings(got) + want := []string{ + `"Reset first" annotates ChangeObjectAction`, + "false branch goes right", + "handler of commit1 logs at Warning", + "true branch goes down", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("findings:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} From 6053a59393b020c7446c83e50d4818825bde2ffc Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 16:12:14 +0100 Subject: [PATCH 03/16] fix(catalog): agent tools match ToolCount; consumed MCP endpoint in the catalog (#2090) CATALOG.AGENT_TOOLS held an agent's consumed MCP services as well as its tools, so it disagreed with AGENTS.ToolCount and the documented "tools that run without approval" query counted MCP services. They are now only in AGENT_MCP_SERVICES, which MCPServiceCount counts. CONSUMED_MCP_SERVICES gains Endpoint and AuthenticationMicroflow, and REFS records the endpoint constant (new `endpoint` kind) and the credentials microflow (`call`), which `list callers` reported as uncalled. Schema 44. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82ttaGzay5CwiC2V7cf --- ...-agent-catalog-mcp-tools-and-endpoint.json | 1 + .../write-lint-rules/security-and-settings.md | 2 +- CHANGELOG.md | 1 + docs-site/src/internals/catalog-schema.md | 1 + docs-site/src/tools/catalog-tables.md | 20 +++++---- mdl/catalog/builder_agent_details_test.go | 45 +++++++++++++++++-- mdl/catalog/builder_agent_refs.go | 10 +++++ mdl/catalog/builder_agenteditor.go | 2 + mdl/catalog/builder_references.go | 3 +- mdl/catalog/tables.go | 26 ++++++++--- 10 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-11-10-agent-catalog-mcp-tools-and-endpoint.json diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-11-10-agent-catalog-mcp-tools-and-endpoint.json b/.claude/skills/fix-issue/findings/mdl-other/2026-11-10-agent-catalog-mcp-tools-and-endpoint.json new file mode 100644 index 000000000..80cfbcf79 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-11-10-agent-catalog-mcp-tools-and-endpoint.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-11-10", "refs": ["ako/mxcli#2090", "ako/mxcli#2075", "ako/mxcli#2060"], "ce": [], "symptom": "CATALOG.AGENTS.ToolCount did not match count(*) over CATALOG.AGENT_TOOLS (FactoryManagement: 5 against 6), because AGENT_TOOLS also held the agent's consumed MCP services, which ToolCount and the documented 'tools that run without approval' query exclude. CATALOG.CONSUMED_MCP_SERVICES had no Endpoint or AuthenticationMicroflow column, and `list callers` of a service's credentials microflow answered '(no callers found)'.", "file": "mdl/catalog/tables.go (agent_tools view filters ToolType <> 'MCP', agent_mcp_services reads agent_tools_data; consumed_mcp_services Endpoint, AuthenticationMicroflow; CatalogSchemaVersion 44), builder_agenteditor.go agentEditorDetail, builder_agent_refs.go agentEditorRefs (ConsumedMCPService: endpoint -> CONSTANT, call -> MICROFLOW), builder_references.go RefKindEndpoint. Tests: builder_agent_details_test.go TestAgentDetails_Issue2075, TestConsumedMCPServiceRefs_Issue2090", "cause": "#2080 stored every tools-list entry in agent_tools_data and exposed it whole as AGENT_TOOLS, with AGENT_MCP_SERVICES a filtered view over it, while the agent row counted the two apart. The consumed MCP service columns and refs predate #2086, which made endpoint and authenticationMicroflow part of the model.", "insight": "**A count column and the table it summarises must be tested against each other, not each against a fixture.** The #2075 test asserted ToolCount and the AGENT_TOOLS rows separately, both 'right', and the mismatch only showed in a query joining them; the new assertion compares ToolCount with count(*) per agent. When a document gains a modelled reference (#2086), its catalog row and refs edge are part of the same change: the reference was writable for a day while `list callers` still called its microflow dead. Control: a main build on the same FactoryManagement copy reports 5 vs 6 and no callers."} diff --git a/.claude/skills/mendix/write-lint-rules/security-and-settings.md b/.claude/skills/mendix/write-lint-rules/security-and-settings.md index aa70212b7..083f4b7a9 100644 --- a/.claude/skills/mendix/write-lint-rules/security-and-settings.md +++ b/.claude/skills/mendix/write-lint-rules/security-and-settings.md @@ -39,7 +39,7 @@ MPR reader, so they need no particular catalog depth. | `target_type` | string | What it points AT, upper-case: `"ENTITY"`, `"ASSOCIATION"`, `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"SNIPPET"`, `"LAYOUT"`, `"WORKFLOW"`, `"WIDGET"`, `"JAVA_ACTION"`, `"JAVASCRIPT_ACTION"`, `"REST_OPERATION"`, `"REGULAR_EXPRESSION"`, `"ATTRIBUTE"`, `"ENUMERATION"`, `"ENUMERATION_VALUE"`, `"IMPORT_MAPPING"`, `"EXPORT_MAPPING"`, `"JSON_STRUCTURE"`, `"AGENT"`, `"AI_MODEL"`, `"KNOWLEDGE_BASE"`, `"CONSUMED_MCP_SERVICE"`, `"CONSTANT"`. `WIDGET`, `ATTRIBUTE`, `ENUMERATION`, `ENUMERATION_VALUE` and `JSON_STRUCTURE` are only ever targets; `SCHEDULED_EVENT`, `PROJECT_SETTINGS` and the service types only ever sources | | `target_id` | string | Target UUID | | `target_name` | string | `"Sales.Customer"`; three-part for an attribute or an enumeration value: `"Sales.Order.Total"`, `"Sales.OrderStatus.Open"` | -| `ref_kind` | string | How it references: `"call"`, `"create"`, `"retrieve"`, `"change"`, `"delete"`, `"commit"` (a commit action, or a create/change that commits — beside its `"create"`/`"change"` edge; a commit of a variable whose entity the flow cannot tell has no edge), `"show_page"`, `"snippet_call"` (a page, snippet or layout places a snippet), `"datasource"`, `"action"`, `"layout"`, `"parameter"` (a page, snippet or flow parameter's entity type), `"return"`, `"generalize"`, `"associate"`, `"home_page"`, `"login_page"`, `"menu_item"`, `"calculate"`, `"schedule"`, `"validate"`, `"settings"`, `"widget"`, `"sync"`, `"publish"` (a published REST operation, OData resource or action, a published MCP service's tool microflow, or a service's authentication microflow), `"event"` (an entity event or a business event handler), `"member"` (binds/reads/writes an attribute, navigates an association, or reaches the entity an association step arrives at), `"xpath"` (an XPath constraint names it, or reaches the entity through an association), `"type"` (typed as an enumeration; or an entity named as a type: an action's result variable, a list a flow creates, an entity-type argument to a Java or JavaScript action), `"value"` (an expression names an enumeration value; an ENUMERATION target when the value named does not exist), `"mapping"` (an import/export mapping maps the entity, or a message definition exposes it), `"split"` (a decision branches on an object being the entity), `"mapping_call"` (a flow applies an import or export mapping: an import/export action, or a REST or web service call's request or response mapping), `"structure"` (a mapping is built on a JSON structure), `"tool"` (an agent draws on a knowledge base or consumed MCP service; an agent's tool microflow, nanoflow or agent is a `"call"`, as is a microflow or agent document passed as a Java action parameter), `"model"` (an agent runs on an AI model), `"key"` (an AI model or knowledge base reads its key from a constant) — lower-case, unlike the types above. Attribute names used only through a variable in a free-text expression (`$Order/Total`) have no edge | +| `ref_kind` | string | How it references: `"call"`, `"create"`, `"retrieve"`, `"change"`, `"delete"`, `"commit"` (a commit action, or a create/change that commits — beside its `"create"`/`"change"` edge; a commit of a variable whose entity the flow cannot tell has no edge), `"show_page"`, `"snippet_call"` (a page, snippet or layout places a snippet), `"datasource"`, `"action"`, `"layout"`, `"parameter"` (a page, snippet or flow parameter's entity type), `"return"`, `"generalize"`, `"associate"`, `"home_page"`, `"login_page"`, `"menu_item"`, `"calculate"`, `"schedule"`, `"validate"`, `"settings"`, `"widget"`, `"sync"`, `"publish"` (a published REST operation, OData resource or action, a published MCP service's tool microflow, or a service's authentication microflow), `"event"` (an entity event or a business event handler), `"member"` (binds/reads/writes an attribute, navigates an association, or reaches the entity an association step arrives at), `"xpath"` (an XPath constraint names it, or reaches the entity through an association), `"type"` (typed as an enumeration; or an entity named as a type: an action's result variable, a list a flow creates, an entity-type argument to a Java or JavaScript action), `"value"` (an expression names an enumeration value; an ENUMERATION target when the value named does not exist), `"mapping"` (an import/export mapping maps the entity, or a message definition exposes it), `"split"` (a decision branches on an object being the entity), `"mapping_call"` (a flow applies an import or export mapping: an import/export action, or a REST or web service call's request or response mapping), `"structure"` (a mapping is built on a JSON structure), `"tool"` (an agent draws on a knowledge base or consumed MCP service; an agent's tool microflow, nanoflow or agent is a `"call"`, as is a microflow or agent document passed as a Java action parameter), `"model"` (an agent runs on an AI model), `"key"` (an AI model or knowledge base reads its key from a constant), `"endpoint"` (a consumed MCP service reads its server URL from a constant) — lower-case, unlike the types above. Attribute names used only through a variable in a free-text expression (`$Order/Total`) have no edge | | `module_name` | string | Source module | ### project_security diff --git a/CHANGELOG.md b/CHANGELOG.md index ff01f0a39..4b1d6bdc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled. - **A consumed MCP service written by mxcli connects to its server** (ako/mxcli#2060) — it had no `endpoint` and no credentials microflow, so Agent Editor skipped it at startup and the agents using it got no tools, while `mx check` stayed green. `create`/`alter consumed mcp service` take `Endpoint: @Module.UrlConstant` (a String constant) and `AuthenticationMicroflow: Module.GetHeaders`, `describe` prints them, `create or modify` keeps stored ones, `check` warns on a `create` without an endpoint (MDL-AGENT03), and `check --references` refuses a non-String constant or a credentials microflow MCP Client cannot call (it must take no parameters and return a list of `System.HttpHeader`). The agents skill and syntax help show it (part of ako/mxcli#2067). - **`create agent` writes the selected model, so Agent Editor imports the agent** (ako/mxcli#2031) — Agent Editor 2.2+ refused every mxcli-created agent at startup ("Model with name is referenced but could not be found"), and one refused agent stopped the import of all of them, while `mx check` and the build stayed green. `create`/`alter agent` take `SelectedModel: ''` (and an optional `SelectedModelName`, defaulting to the id), `describe` prints it, and `check` warns on a `create agent` without one (MDL-AGENT02). The skill, syntax help and examples now show it (part of ako/mxcli#2067). - **`move scheduled event M.Nightly` moves the scheduled event when a microflow shares its name** (ako/mxcli#2072, mendixlabs/mxcli#1792) — the move looked the name up regardless of type, met the microflow first and refused with "M.Nightly is a microflow, not a scheduled event". It now picks the document of the type the statement names; a name no document of that type has is still refused, naming what it is. diff --git a/docs-site/src/internals/catalog-schema.md b/docs-site/src/internals/catalog-schema.md index ee58d46b3..49e2de9fa 100644 --- a/docs-site/src/internals/catalog-schema.md +++ b/docs-site/src/internals/catalog-schema.md @@ -319,6 +319,7 @@ rather than trusting a list here: | `tool` | agent draws on a knowledge base or consumed MCP service (a tool microflow, nanoflow or agent is a `call`) | | `model` | agent runs on an AI model | | `key` | AI model or knowledge base reads its key from a constant | +| `endpoint` | consumed MCP service reads its server URL from a constant (its credentials microflow is a `call`) | | `validate` | attribute validation rule uses a regular expression | | `member` | document binds, reads or writes an attribute, navigates an association, or reaches the entity an association step arrives at | | `xpath` | XPath constraint names an attribute, association, enumeration value, or an entity reached through an association | diff --git a/docs-site/src/tools/catalog-tables.md b/docs-site/src/tools/catalog-tables.md index 399fd2c52..0749cf34f 100644 --- a/docs-site/src/tools/catalog-tables.md +++ b/docs-site/src/tools/catalog-tables.md @@ -495,21 +495,22 @@ than `0`, so a query can tell "off" from "never set". ### CATALOG.AGENT_TOOLS -One row per entry of an agent's tools list: a microflow, nanoflow or agent it -calls, or a consumed MCP service (`ToolType = 'MCP'`). +One row per entry of an agent's tools list that `ToolCount` counts: a +microflow, nanoflow or agent it calls. Its consumed MCP services are in +`CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. | Column | Description | |--------|-------------| | `AgentId`, `Agent` | The agent's id and qualified name | | `Name`, `Description` | What the model sees | -| `ToolType` | `Microflow`, `MCP`, or empty | -| `Document` | The microflow, nanoflow, agent or MCP service, qualified name | +| `ToolType` | `Microflow`, or empty | +| `Document` | The microflow, nanoflow or agent, qualified name | | `Enabled` | `1` or `0` | | `UserAccessApproval` | `HiddenForUser`, `VisibleForUser`, `UserConfirmationRequired`, or empty when not stored | | `DisplayTitle`, `DisplayDescription` | What the user sees of the call, `NULL` when not stored | -`CATALOG.AGENT_MCP_SERVICES` is the same rows filtered to `ToolType = 'MCP'`, -with `Document` named `Service`. +`CATALOG.AGENT_MCP_SERVICES` has the same columns for the agent's MCP services, +with `Document` named `Service` and no `ToolType`. ```sql -- Tools an agent can run without the user's approval @@ -529,11 +530,12 @@ One row per entry of an agent's knowledge base tools list: `Agent`, `Name`, |-------|-------------------------------| | `CATALOG.AI_MODELS` | `Provider`, `ModelType`, `DisplayName`, `KeyConstant` | | `CATALOG.KNOWLEDGE_BASES` | `Provider`, `KeyConstant` | -| `CATALOG.CONSUMED_MCP_SERVICES` | `ProtocolVersion`, `Version`, `ConnectionTimeoutSeconds` | +| `CATALOG.CONSUMED_MCP_SERVICES` | `ProtocolVersion`, `Version`, `ConnectionTimeoutSeconds`, `Endpoint` (the String constant holding the server URL, empty when none: the service connects nowhere), `AuthenticationMicroflow` | | `CATALOG.PUBLISHED_MCP_SERVICES` | `ServerName`, `EndpointPath`, `Version`, `ProtocolVersion`, `AuthenticationEnabled`, `AuthenticationMicroflow`, `ToolCount` | -What an agent references (its tools, model, knowledge bases and context entity) -and what a published MCP service runs are also edges in `CATALOG.REFS`, so +What an agent references (its tools, model, knowledge bases and context entity), +what a consumed MCP service reads and calls (its endpoint constant and +credentials microflow) and what a published MCP service runs are also edges in `CATALOG.REFS`, so `show callers` and `show impact` see them. ## Graph-Analysis Tables diff --git a/mdl/catalog/builder_agent_details_test.go b/mdl/catalog/builder_agent_details_test.go index 98bdacd21..c77ac12b2 100644 --- a/mdl/catalog/builder_agent_details_test.go +++ b/mdl/catalog/builder_agent_details_test.go @@ -81,6 +81,10 @@ func agentDetailsFixture(t *testing.T) *Catalog { agentEditorRawUnit(t, "m-1", "Gpt", types.CustomTypeModel, `{"type":"Text","name":"gpt","displayName":"GPT", "provider":"MxCloudGenAI","providerFields":{"key":{"qualifiedName":"Agents.GptKey"}}}`), agentEditorRawUnit(t, "mcp-1", "JiraMCP", types.CustomTypeConsumedMCPService, + `{"endpoint":{"qualifiedName":"Agents.JiraUrl"},"protocolVersion":"v2025_03_26","version":"1.0", + "connectionTimeoutSeconds":30,"authenticationMicroflow":{"qualifiedName":"Agents.Jira_GetHeaders"}}`), + // Control: a service with no endpoint, the #2060 failure, reads empty. + agentEditorRawUnit(t, "mcp-2", "NoEndpointMCP", types.CustomTypeConsumedMCPService, `{"protocolVersion":"v2025_03_26","version":"1.0","connectionTimeoutSeconds":30}`), agentEditorRawUnit(t, "pub-1", "Published_MCP_service", types.CustomTypePublishedMCPService, agentEditorFixtureContents(t, "AgentBuilderStarterApp.Published_MCP_service.11_16.json")), @@ -172,8 +176,13 @@ func TestAgentDetails_Issue2075(t *testing.T) { FROM agent_tools ORDER BY Agent, Name`, "Agents.GraphExplorer|Tool_ExceuteSparql|Microflow|GraphPilot_Explorer.Tool_ExecuteSparql|1|VisibleForUser|Tool: Execute Sparql", "Agents.RefundAgent|IssueRefund|Microflow|Agents.Tool_IssueRefund|1|UserConfirmationRequired|∅", - "Agents.RefundAgent|Jira|MCP|Agents.JiraMCP|0||∅", ) + // AGENT_TOOLS holds what ToolCount counts; the MCP services are in + // AGENT_MCP_SERVICES only (ako/mxcli#2090). + assertAgentRows(t, cat, `SELECT a.Name, a.ToolCount, (SELECT count(*) FROM agent_tools t WHERE t.Agent = a.QualifiedName), + a.MCPServiceCount, (SELECT count(*) FROM agent_mcp_services m WHERE m.Agent = a.QualifiedName) + FROM agents a WHERE a.Name = 'RefundAgent'`, + "RefundAgent|1|1|1|1") assertAgentRows(t, cat, `SELECT Agent, Service, Enabled FROM agent_mcp_services`, "Agents.RefundAgent|Agents.JiraMCP|0") assertAgentRows(t, cat, `SELECT Agent, Name, KnowledgeBase, Collection, MaxResults, MinSimilarity, IsVisible, Enabled @@ -184,8 +193,11 @@ func TestAgentDetails_Issue2075(t *testing.T) { "Gpt|MxCloudGenAI|Text|GPT|Agents.GptKey") assertAgentRows(t, cat, `SELECT Name, Provider, KeyConstant FROM knowledge_bases`, "KB_WorkInstructions|MxCloudGenAI|AgenticAssistants.KB_WorkInstructionsKey") - assertAgentRows(t, cat, `SELECT Name, ProtocolVersion, Version, ConnectionTimeoutSeconds FROM consumed_mcp_services`, - "JiraMCP|v2025_03_26|1.0|30") + assertAgentRows(t, cat, `SELECT Name, ProtocolVersion, Version, ConnectionTimeoutSeconds, + Endpoint, AuthenticationMicroflow + FROM consumed_mcp_services ORDER BY Name`, + "JiraMCP|v2025_03_26|1.0|30|Agents.JiraUrl|Agents.Jira_GetHeaders", + "NoEndpointMCP|v2025_03_26|1.0|30||") assertAgentRows(t, cat, `SELECT Name, ServerName, EndpointPath, Version, ProtocolVersion, AuthenticationEnabled, AuthenticationMicroflow, ToolCount FROM published_mcp_services`, "Published_MCP_service|Published_MCP_service|published_mcp_service|1.0.0|v2025_03_26|0||1") @@ -213,3 +225,30 @@ func TestPublishedMCPServiceRefs_Issue2075(t *testing.T) { t.Errorf("published MCP service edges = %q, want %q", got, want) } } + +// A consumed MCP service reads its server URL from a String constant and +// calls its credentials microflow for the request headers: without the edges +// GRAPH_DEAD_ASSETS lists both, and `show callers` finds no user +// (ako/mxcli#2090). +func TestConsumedMCPServiceRefs_Issue2090(t *testing.T) { + u := agentEditorRawUnit(t, "mcp-1", "JiraMCP", types.CustomTypeConsumedMCPService, + `{"endpoint":{"qualifiedName":"Agents.JiraUrl"},"protocolVersion":"v2025_03_26", + "authenticationMicroflow":{"qualifiedName":"Agents.Jira_GetHeaders"}}`) + doc, err := mprread.DecodeAgentEditorUnit("mcp-1", string(agentDetailsModuleID), u.Contents) + if err != nil { + t.Fatal(err) + } + want := "call MICROFLOW Agents.Jira_GetHeaders|endpoint CONSTANT Agents.JiraUrl" + if got := strings.Join(edgeStrings(agentEditorRefs(doc, resolveFixture)), "|"); got != want { + t.Errorf("consumed MCP service edges = %q, want %q", got, want) + } + // Control: a service with neither has no edges. + bare := agentEditorRawUnit(t, "mcp-2", "Bare", types.CustomTypeConsumedMCPService, `{"version":"1.0"}`) + doc, err = mprread.DecodeAgentEditorUnit("mcp-2", string(agentDetailsModuleID), bare.Contents) + if err != nil { + t.Fatal(err) + } + if got := edgeStrings(agentEditorRefs(doc, resolveFixture)); len(got) != 0 { + t.Errorf("bare service edges = %q, want none", got) + } +} diff --git a/mdl/catalog/builder_agent_refs.go b/mdl/catalog/builder_agent_refs.go index 9617f4214..02686d1e9 100644 --- a/mdl/catalog/builder_agent_refs.go +++ b/mdl/catalog/builder_agent_refs.go @@ -58,6 +58,9 @@ func agentEditorSourceType(doc any) string { // service or a knowledge base it draws on is a `tool`; // - an agent runs on its `model`, and is typed on its context entity (`type`); // - a model or knowledge base reads its key from a constant (`key`); +// - a consumed MCP service reads its server URL from a constant +// (`endpoint`) and calls its credentials microflow (`call`, as MCPClient +// runs it for every request; ako/mxcli#2090); // - a published MCP service runs each tool's microflow and its // authentication microflow (`publish`, as a published REST or OData // service does; ako/mxcli#2075). @@ -112,6 +115,13 @@ func agentEditorRefs(doc any, resolve func(string) string) []docRef { if d.Key != nil { add(d.Key.QualifiedName, RefObjectConstant, RefKindKey) } + case *types.ConsumedMCPService: + if d.Endpoint != nil { + add(d.Endpoint.QualifiedName, RefObjectConstant, RefKindEndpoint) + } + if d.AuthenticationMicroflow != nil { + add(d.AuthenticationMicroflow.QualifiedName, RefObjectMicroflow, RefKindCall) + } case *types.PublishedMCPService: for _, tool := range d.Tools { if tool.SourceMicroflow != nil { diff --git a/mdl/catalog/builder_agenteditor.go b/mdl/catalog/builder_agenteditor.go index ec293abaf..aebd5a726 100644 --- a/mdl/catalog/builder_agenteditor.go +++ b/mdl/catalog/builder_agenteditor.go @@ -119,6 +119,8 @@ func agentEditorDetail(doc any) agentEditorColumns { c.add("ProtocolVersion", d.ProtocolVersion) c.add("Version", d.Version) c.add("ConnectionTimeoutSeconds", d.ConnectionTimeoutSeconds) + c.add("Endpoint", docRefName(d.Endpoint)) + c.add("AuthenticationMicroflow", docRefName(d.AuthenticationMicroflow)) case *types.PublishedMCPService: c.add("ServerName", d.ServerName) c.add("EndpointPath", d.EndpointPath) diff --git a/mdl/catalog/builder_references.go b/mdl/catalog/builder_references.go index d057b837c..af9ad3531 100644 --- a/mdl/catalog/builder_references.go +++ b/mdl/catalog/builder_references.go @@ -50,6 +50,7 @@ const ( RefKindTool = "tool" // An agent draws on a knowledge base or consumed MCP service (a tool microflow, nanoflow or agent is a call) RefKindModel = "model" // An agent runs on an AI model RefKindKey = "key" // An AI model or knowledge base reads its key from a constant + RefKindEndpoint = "endpoint" // A consumed MCP service reads its server URL from a constant ) // RefKinds is every value that reaches refs.RefKind. It is the vocabulary a @@ -68,7 +69,7 @@ var RefKinds = []string{ RefKindEvent, RefKindMember, RefKindXPath, RefKindType, RefKindValue, RefKindMapping, RefKindSplit, RefKindSnippetCall, RefKindMappingCall, RefKindStructure, RefKindTool, RefKindModel, - RefKindKey, + RefKindKey, RefKindEndpoint, } // Object types recorded in refs.SourceType and refs.TargetType — the catalog's diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 304cb3964..f0bd6916c 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,12 @@ package catalog // // History: // +// 44 (agent tools and MCP endpoints): agent_tools no longer holds an +// agent's MCP services, which agent_mcp_services already lists, so it +// matches ToolCount; consumed_mcp_services gains Endpoint and +// AuthenticationMicroflow, and refs an `endpoint` edge to the constant +// and a `call` to the microflow (ako/mxcli#2090). Views are CREATE VIEW +// IF NOT EXISTS, so without the bump a cached catalog keeps both. // 43 (agent prompts): strings gains an agent's description, system and // user prompts, and its tool and knowledge base descriptions // (ako/mxcli#2075). Without the bump a cached catalog keeps answering @@ -235,7 +241,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "43" +const CatalogSchemaVersion = "44" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -838,6 +844,7 @@ func (c *Catalog) createTables() error { Id TEXT PRIMARY KEY, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, Description TEXT, ProtocolVersion TEXT, Version TEXT, ConnectionTimeoutSeconds INTEGER, + Endpoint TEXT, AuthenticationMicroflow TEXT, ProjectId TEXT, SnapshotId TEXT )`, viewWithFullSnapshot("consumed_mcp_services"), @@ -851,7 +858,10 @@ func (c *Catalog) createTables() error { )`, viewWithFullSnapshot("published_mcp_services"), // One row per entry of an agent's tools list: a microflow, nanoflow - // or agent it calls, or a consumed MCP service (ToolType 'MCP'). + // or agent it calls, or a consumed MCP service (ToolType 'MCP'). The + // agent_tools view holds the tools ToolCount counts, agent_mcp_services + // the MCP services MCPServiceCount counts, as the Agent Editor lists + // them apart (ako/mxcli#2090). `CREATE TABLE IF NOT EXISTS agent_tools_data ( Id TEXT, AgentId TEXT, Agent TEXT, ModuleName TEXT, Name TEXT, ToolType TEXT, Document TEXT, Enabled INTEGER, @@ -859,13 +869,15 @@ func (c *Catalog) createTables() error { DisplayTitle TEXT, DisplayDescription TEXT, ProjectId TEXT, SnapshotId TEXT )`, - viewWithFullSnapshot("agent_tools"), + viewWithFullSnapshotOver("agent_tools", "agent_tools_data") + ` WHERE IFNULL(t.ToolType, '') <> 'MCP'`, // The MCP service tools, under the names the Agent Editor shows them. `CREATE VIEW IF NOT EXISTS agent_mcp_services AS - SELECT Id, AgentId, Agent, ModuleName, Name, Document AS Service, - Enabled, UserAccessApproval, Description, - ProjectId, ProjectName, SnapshotId, SnapshotDate, SnapshotSource - FROM agent_tools WHERE ToolType = 'MCP'`, + SELECT t.Id, t.AgentId, t.Agent, t.ModuleName, t.Name, t.Document AS Service, + t.Enabled, t.UserAccessApproval, t.Description, + t.ProjectId, s.ProjectName, t.SnapshotId, s.SnapshotDate, s.SnapshotSource + FROM agent_tools_data t + LEFT JOIN snapshots s ON s.SnapshotId = t.SnapshotId + WHERE t.ToolType = 'MCP'`, // One row per entry of an agent's knowledge base tools list. `CREATE TABLE IF NOT EXISTS agent_knowledge_bases_data ( Id TEXT, AgentId TEXT, Agent TEXT, ModuleName TEXT, From cec8c93d028a6b9856f529d5d287512a08a41c89 Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 16:30:27 +0100 Subject: [PATCH 04/16] fix(check): flag create or modify agents and MCP services left without model or endpoint (#2089) MDL-AGENT02/03 skipped every create or modify, because a key it leaves out keeps the stored value. That also skipped a new document, which is how project scripts and describe create one. The reference pass, shared by check --references and exec, now replays the script over the stored documents and warns when there is nothing to keep. The agent half is gated on AgentEditorCommons 2.2+, the first version that reads selectedModel. Lint gains MPR014 (agent without a selected model) and MPR015 (consumed MCP service without an endpoint), over the catalog, so a stored document is caught whoever wrote it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82ttaGzay5CwiC2V7cf --- .claude/commands/mendix/lint.md | 2 + ...10-agent-check-skips-create-or-modify.json | 1 + .claude/skills/mendix/agents/SKILL.md | 21 +-- CHANGELOG.md | 1 + cmd/mxcli/cmd_lint.go | 2 + docs-site/src/appendixes/error-messages.md | 12 +- docs-site/src/reference/agent/create-agent.md | 2 +- .../agent/create-consumed-mcp-service.md | 2 +- mdl/executor/cmd_lint.go | 4 + mdl/executor/validate.go | 4 + mdl/executor/validate_agent_left_unset.go | 117 +++++++++++++++++ .../validate_agent_left_unset_test.go | 117 +++++++++++++++++ mdl/executor/validate_agent_selected_model.go | 4 +- .../validate_consumed_mcp_endpoint.go | 5 +- mdl/linter/context_catalog_tables.go | 65 ++++++++++ mdl/linter/rules/agent_documents.go | 121 ++++++++++++++++++ mdl/linter/rules/agent_documents_test.go | 81 ++++++++++++ 17 files changed, 543 insertions(+), 18 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-10-agent-check-skips-create-or-modify.json create mode 100644 mdl/executor/validate_agent_left_unset.go create mode 100644 mdl/executor/validate_agent_left_unset_test.go create mode 100644 mdl/linter/rules/agent_documents.go create mode 100644 mdl/linter/rules/agent_documents_test.go diff --git a/.claude/commands/mendix/lint.md b/.claude/commands/mendix/lint.md index 8456cddec..b087bde3c 100644 --- a/.claude/commands/mendix/lint.md +++ b/.claude/commands/mendix/lint.md @@ -46,6 +46,8 @@ mxcli lint -p app.mpr --baseline lint-baseline.json | MPR007 | security | PageNavigationSecurity - Navigation pages need allowed roles (CE0557); every user role must be able to open its home page (at Production security, else CE2729 per widget) | | MPR012 | correctness | ReactUnsupportedWidget - static/dynamic image, drop-down, legacy data grid and reference selector are unsupported by the React client (CE0582; info on the classic client) | | MPR013 | correctness | SnippetCallAppearance - Class/Style/DynamicClasses/design properties on a snippet call never reach the page (it renders no element) | +| MPR014 | correctness | AgentWithoutSelectedModel - an agent with no selected model, which Agent Editor 2.2+ refuses at startup, stopping the import of every agent (not reported for AgentEditorCommons before 2.2) | +| MPR015 | correctness | ConsumedMCPServiceWithoutEndpoint - a consumed MCP service with no endpoint constant, which Agent Editor skips at startup | | SEC001 | security | NoEntityAccessRules - Persistent entities need access rules | | SEC002 | security | WeakPasswordPolicy - Password minimum length should be 8+ | | SEC003 | security | DemoUsersActive - Demo users should be off at Production security | diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-agent-check-skips-create-or-modify.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-agent-check-skips-create-or-modify.json new file mode 100644 index 000000000..22de63a30 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-agent-check-skips-create-or-modify.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-10", "refs": ["ako/mxcli#2089", "ako/mxcli#2031", "ako/mxcli#2060"], "ce": [], "symptom": "MDL-AGENT02 (agent without SelectedModel) and MDL-AGENT03 (consumed MCP service without Endpoint) never fired for `create or modify`, the form project scripts and describe use to create a document, so a new agent or service written that way passed check, check --references and exec silently; and lint flagged no stored document without either.", "file": "mdl/executor/validate_agent_left_unset.go (warnAgentDocumentsLeftUnset, agentEditorReadsSelectedModel), wired in validate.go validateProgramWithWarnings (check --references and exec preflight); mdl/linter/rules/agent_documents.go (MPR014 AgentWithoutSelectedModel, MPR015 ConsumedMCPServiceWithoutEndpoint), mdl/linter/context_catalog_tables.go (Agents, ConsumedMCPServices, MarketplaceModuleVersion), registered in cmd/mxcli/cmd_lint.go and mdl/executor/cmd_lint.go. Tests: validate_agent_left_unset_test.go, rules/agent_documents_test.go", "cause": "The static rules skipped every create or modify because a key it leaves out keeps the stored value; a static rule cannot see whether a value is stored, so the skip also covered a new document, which has nothing to keep.", "insight": "**A rule that exempts `create or modify` because it 'keeps the stored value' needs a project-aware half, or it exempts the most common way to create.** The static pass cannot distinguish new from existing; the reference pass (validateProgramWithWarnings, shared by check --references and exec) can, replaying drops, creates and alters in statement order over the stored documents. The selected-model half is version-gated: AgentEditorCommons 1.1.0 does not read selectedModel, and FactoryManagement's agents (1.1.0) have none, so an ungated rule would flag every agent of a working app. MPR014 on a scratch ICXGraphPilot copy flagged an agent that the original has a selected model for: that copy had been through a pre-#2031 describe/exec, which drops it; a fresh copy lints clean. Controls: stubbing the pass, removing either version gate, and an empty ConsumedMCPServices accessor each fail their test; a build without the change passes the issue's script with `Check passed!` and no warning."} diff --git a/.claude/skills/mendix/agents/SKILL.md b/.claude/skills/mendix/agents/SKILL.md index d02ce4bb9..7f838138a 100644 --- a/.claude/skills/mendix/agents/SKILL.md +++ b/.claude/skills/mendix/agents/SKILL.md @@ -11,12 +11,12 @@ description: "Author Mendix AI agent documents in MDL — Model, Knowledge Base, | lines | section | |---|---| | 24-50 | Overview | -| 51-192 | Syntax | -| 193-219 | Altering existing documents | -| 220-268 | Gotchas | -| 269-291 | Common Patterns | -| 292-356 | Calling Agents from Microflows | -| 357-359 | AI agent documents | +| 51-194 | Syntax | +| 195-221 | Altering existing documents | +| 222-273 | Gotchas | +| 274-296 | Common Patterns | +| 297-361 | Calling Agents from Microflows | +| 362-364 | AI agent documents | # Agents @@ -108,7 +108,9 @@ server URL. At startup Agent Editor reads the constant's value and, when there is none, skips the service ("No value could be found for the local configuration for the constant"), so an agent's `mcp service` block gets no tools. `check` warns on a `create` without it (MDL-AGENT03), and -`check --references` refuses a constant that is not a String. +`check --references` and `exec` on a `create or modify` that leaves a new or +stored service without one; `check --references` refuses a constant that is +not a String. `mxcli lint` reports a stored service without one (MPR015). `AuthenticationMicroflow` supplies credentials: MCP Client calls it with **no parameters** and sends the `List of System.HttpHeader` it returns with every @@ -225,7 +227,10 @@ the models the Model document's resource offers. Without it, `ASU_AgentEditor` logs "Model with name is referenced but could not be found for the Agent" at startup and **stops importing every agent document**, not just that one. `mx check` and the build stay green. `mxcli check` warns on a `create agent` -without it (MDL-AGENT02). +without it (MDL-AGENT02), and `check --references` and `exec` on a +`create or modify agent` that leaves a new or stored agent without one. +`mxcli lint` reports a stored agent without one (MPR014). Agent Editor 1.x +does not read it, so neither warns there. ```sql mdl 1; diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1d6bdc9..bb86df25b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. - **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled. - **A consumed MCP service written by mxcli connects to its server** (ako/mxcli#2060) — it had no `endpoint` and no credentials microflow, so Agent Editor skipped it at startup and the agents using it got no tools, while `mx check` stayed green. `create`/`alter consumed mcp service` take `Endpoint: @Module.UrlConstant` (a String constant) and `AuthenticationMicroflow: Module.GetHeaders`, `describe` prints them, `create or modify` keeps stored ones, `check` warns on a `create` without an endpoint (MDL-AGENT03), and `check --references` refuses a non-String constant or a credentials microflow MCP Client cannot call (it must take no parameters and return a list of `System.HttpHeader`). The agents skill and syntax help show it (part of ako/mxcli#2067). - **`create agent` writes the selected model, so Agent Editor imports the agent** (ako/mxcli#2031) — Agent Editor 2.2+ refused every mxcli-created agent at startup ("Model with name is referenced but could not be found"), and one refused agent stopped the import of all of them, while `mx check` and the build stayed green. `create`/`alter agent` take `SelectedModel: ''` (and an optional `SelectedModelName`, defaulting to the id), `describe` prints it, and `check` warns on a `create agent` without one (MDL-AGENT02). The skill, syntax help and examples now show it (part of ako/mxcli#2067). diff --git a/cmd/mxcli/cmd_lint.go b/cmd/mxcli/cmd_lint.go index d3b206a0f..c81571876 100644 --- a/cmd/mxcli/cmd_lint.go +++ b/cmd/mxcli/cmd_lint.go @@ -481,6 +481,8 @@ func builtinLintRules() []linter.Rule { rules.NewImageSourceRule(), rules.NewReactUnsupportedWidgetRule(), rules.NewSnippetCallAppearanceRule(), + rules.NewAgentWithoutSelectedModelRule(), + rules.NewConsumedMCPServiceWithoutEndpointRule(), rules.NewEmptyContainerRule(), rules.NewGallerySelectionListenerRule(), rules.NewDataViewLayoutGridRule(), diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index 647d0ae2b..f5d961c3a 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -295,8 +295,11 @@ documents, so the build is green; `ASU_AgentEditor` refuses the agent at startup importing the rest. **Solution:** Add `SelectedModel: ''` (e.g. `'eu.anthropic.claude-sonnet-5'`), the id -of a model the Model document's resource offers. `create or modify agent` is not flagged: left -out, it keeps the stored value. +of a model the Model document's resource offers. `create or modify agent` keeps a stored value +when the key is left out, so `check --references` and `exec` warn on it only when there is +none to keep: a new agent, one the script dropped, or a stored agent that has none. They do not +warn when the project's AgentEditorCommons predates 2.2, which does not read the value. `mxcli +lint` reports stored agents without one as MPR014. ### MDL-AGENT03: A consumed MCP service without an endpoint @@ -312,8 +315,9 @@ service otherwise. mxbuild does not read agent documents, so the build is green; use the service get none of its tools. **Solution:** Add `Endpoint: @Module.UrlConstant`, a String constant holding the server URL, and -set its value per environment. `create or modify` is not flagged: left out, it keeps the stored -endpoint. +set its value per environment. `create or modify` keeps a stored endpoint when the key is left +out, so `check --references` and `exec` warn on it only when there is none to keep. `mxcli lint` +reports stored services without one as MPR015. ### MDL-LISTOP02: A list operation nested inside another one diff --git a/docs-site/src/reference/agent/create-agent.md b/docs-site/src/reference/agent/create-agent.md index c9efa3b2c..c5b905c9d 100644 --- a/docs-site/src/reference/agent/create-agent.md +++ b/docs-site/src/reference/agent/create-agent.md @@ -84,7 +84,7 @@ If `OR MODIFY` is specified and the agent already exists, its properties and bod : Qualified reference to the model document the agent uses. `SelectedModel` -: The id of the model the agent uses, one the Model document's resource offers (e.g. `'eu.anthropic.claude-sonnet-5'`). Required in practice from Agent Editor 2.2: its import refuses an agent without one ("Model with name is referenced but could not be found for the Agent"), and that refusal stops the import of every agent document at startup. `mxcli check` warns on a `create agent` without it (MDL-AGENT02). Only the id is read at runtime; it is matched against `GenAICommons.DeployedModel.Model`. To list the ids, with the app running: `mxcli oql "select Model from MxGenAIConnector.MxCloudDeployedModel"` (reported to work in ako/mxcli#2031). `''` clears it. +: The id of the model the agent uses, one the Model document's resource offers (e.g. `'eu.anthropic.claude-sonnet-5'`). Required in practice from Agent Editor 2.2: its import refuses an agent without one ("Model with name is referenced but could not be found for the Agent"), and that refusal stops the import of every agent document at startup. `mxcli check` warns on a `create agent` without it (MDL-AGENT02), `check --references` and `exec` on a `create or modify` that leaves the agent without one, and `mxcli lint` on a stored agent without one (MPR014). Only the id is read at runtime; it is matched against `GenAICommons.DeployedModel.Model`. To list the ids, with the app running: `mxcli oql "select Model from MxGenAIConnector.MxCloudDeployedModel"` (reported to work in ako/mxcli#2031). `''` clears it. `SelectedModelName` : Optional. The display name Studio Pro stores next to the id (e.g. `'Anthropic Claude Sonnet 5'`); nothing at runtime reads it. Defaults to the id, or keeps the stored name while the id is unchanged. diff --git a/docs-site/src/reference/agent/create-consumed-mcp-service.md b/docs-site/src/reference/agent/create-consumed-mcp-service.md index 5bab17187..470c11532 100644 --- a/docs-site/src/reference/agent/create-consumed-mcp-service.md +++ b/docs-site/src/reference/agent/create-consumed-mcp-service.md @@ -34,7 +34,7 @@ If `OR MODIFY` is specified and the service already exists, its properties are u : The MCP protocol version. Use the token form (e.g. `v2025_03_26`), not a quoted string. `Endpoint: @module.UrlConstant` -: The String constant holding the MCP server's URL (e.g. `https://example.com/mcp`). Required in practice: at startup Agent Editor reads the constant's value and skips a service whose constant has none ("No value could be found for the local configuration for the constant"), so the agents that use it get no tools. `mxcli check` warns on a `CREATE` without it (MDL-AGENT03); `check --references` and `exec` refuse a constant that does not exist or is not a String. Set the value per environment. On `OR MODIFY`, left out, the stored endpoint stays. +: The String constant holding the MCP server's URL (e.g. `https://example.com/mcp`). Required in practice: at startup Agent Editor reads the constant's value and skips a service whose constant has none ("No value could be found for the local configuration for the constant"), so the agents that use it get no tools. `mxcli check` warns on a `CREATE` without it (MDL-AGENT03), `check --references` and `exec` on an `OR MODIFY` that leaves the service without one, and `mxcli lint` on a stored service without one (MPR015); `check --references` and `exec` refuse a constant that does not exist or is not a String. Set the value per environment. On `OR MODIFY`, left out, the stored endpoint stays. `AuthenticationMicroflow: module.GetHeaders` : Optional. The microflow that supplies credentials. MCP Client calls it with no arguments and sends the `List of System.HttpHeader` it returns with each request (build the list with `MCPClient.HttpHeader_CreateAndAddToList`). `check --references` refuses one with parameters or another return type. On `OR MODIFY`, left out, the stored microflow stays; `ALTER … SET AuthenticationMicroflow = ''` removes it. diff --git a/mdl/executor/cmd_lint.go b/mdl/executor/cmd_lint.go index 9dfeb00c6..b4ca61606 100644 --- a/mdl/executor/cmd_lint.go +++ b/mdl/executor/cmd_lint.go @@ -37,6 +37,8 @@ func execLint(ctx *ExecContext, s *ast.LintStmt) error { rules.NewImageSourceRule(), rules.NewReactUnsupportedWidgetRule(), rules.NewSnippetCallAppearanceRule(), + rules.NewAgentWithoutSelectedModelRule(), + rules.NewConsumedMCPServiceWithoutEndpointRule(), rules.NewMissingTranslationsRule(), rules.NewRequiredCaptionDefaultLanguageRule(), rules.NewGallerySelectionListenerRule(), @@ -155,6 +157,8 @@ func listLintRules(ctx *ExecContext) error { lint.AddRule(rules.NewImageSourceRule()) lint.AddRule(rules.NewReactUnsupportedWidgetRule()) lint.AddRule(rules.NewSnippetCallAppearanceRule()) + lint.AddRule(rules.NewAgentWithoutSelectedModelRule()) + lint.AddRule(rules.NewConsumedMCPServiceWithoutEndpointRule()) lint.AddRule(rules.NewMissingTranslationsRule()) lint.AddRule(rules.NewRequiredCaptionDefaultLanguageRule()) lint.AddRule(rules.NewGallerySelectionListenerRule()) diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 93298bce0..f7a482970 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -623,6 +623,10 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // toString() into a String(n): builds, and overflows the attribute // on commit (mendixlabs/mxcli#1515). Warns, never blocks. validateToStringIntoShortAttribute(ctx, prog, sc) + // A create or modify that leaves an agent without a selected model or a + // consumed MCP service without an endpoint: MDL-AGENT02/03 for the case + // only the project can see (ako/mxcli#2089). Warns, never blocks. + warnAgentDocumentsLeftUnset(ctx, prog, sc) return errors, sc.warnings } diff --git a/mdl/executor/validate_agent_left_unset.go b/mdl/executor/validate_agent_left_unset.go new file mode 100644 index 000000000..2362a8864 --- /dev/null +++ b/mdl/executor/validate_agent_left_unset.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// warnAgentDocumentsLeftUnset is the project half of MDL-AGENT02 and +// MDL-AGENT03 (ako/mxcli#2089): a create or modify that leaves an agent with +// no selected model, or a consumed MCP service with no endpoint. +// +// The static rules skip create or modify, because a key it leaves out keeps +// the stored value. But it is also how project scripts and describe create a +// document, and a new one has nothing to keep. Only the project can tell the +// two apart, so this pass, run by check --references and by exec, replays the +// script in statement order over what is stored: a drop leaves nothing to +// keep, an earlier create or alter decides what a later statement keeps. +// A plain create is the static rules' case and is not reported twice. +// +// The agent half applies only when AgentEditorCommons reads selectedModel +// (2.2 and later); every measured version reads the endpoint. +func warnAgentDocumentsLeftUnset(ctx *ExecContext, prog *ast.Program, sc *scriptContext) { + if ctx == nil || prog == nil || sc == nil || !ctx.Connected() { + return + } + agentHas := map[string]bool{} + svcHas := map[string]bool{} + readsSelected := -1 // unknown until an agent needs it + for _, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreateAgentStmt: + qn := s.Name.String() + has, ok := false, false + if s.SelectedModel != nil { + has, ok = *s.SelectedModel != "", true + } else if s.CreateOrModify { + if has, ok = agentHas[qn]; !ok { + if a := findAgentEditorAgent(ctx, s.Name.Module, s.Name.Name); a != nil { + has = a.SelectedModel != nil && a.SelectedModel.ID != "" + } + } + } + agentHas[qn] = has + if has || !s.CreateOrModify { + continue + } + if readsSelected < 0 { + readsSelected = 0 + if agentEditorReadsSelectedModel(ctx) { + readsSelected = 1 + } + } + if readsSelected == 1 { + sc.warnings = append(sc.warnings, fmt.Sprintf( + "%s: agent %s is left with no SelectedModel: Agent Editor 2.2+ refuses to import it at startup "+ + "(\"Model with name is referenced but could not be found\"), and that stops the import of every agent. "+ + "Add SelectedModel: ''", agentSelectedModelRule, qn)) + } + case *ast.AlterAgentStmt: + for k, v := range s.Sets { + if strings.EqualFold(k, "SelectedModel") { + agentHas[s.Name.String()] = v != "" + } + } + case *ast.DropAgentStmt: + agentHas[s.Name.String()] = false + case *ast.CreateConsumedMCPServiceStmt: + qn := s.Name.String() + has, ok := s.Endpoint != nil, s.Endpoint != nil + if !ok && s.CreateOrModify { + if has, ok = svcHas[qn]; !ok { + if c := findAgentEditorConsumedMCPService(ctx, s.Name.Module, s.Name.Name); c != nil { + has = c.Endpoint != nil && c.Endpoint.QualifiedName != "" + } + } + } + svcHas[qn] = has + if has || !s.CreateOrModify { + continue + } + sc.warnings = append(sc.warnings, fmt.Sprintf( + "%s: consumed mcp service %s is left with no Endpoint: Agent Editor skips it at startup "+ + "(\"No value could be found for the local configuration for the constant\"), so it connects nowhere. "+ + "Add Endpoint: @Module.UrlConstant", consumedMCPEndpointRule, qn)) + case *ast.AlterConsumedMCPServiceStmt: + for k, v := range s.Changes { + if strings.EqualFold(k, "Endpoint") { + svcHas[s.Name.String()] = v != "" + } + } + case *ast.DropConsumedMCPServiceStmt: + svcHas[s.Name.String()] = false + } + } +} + +// agentEditorReadsSelectedModel reports whether the project's +// AgentEditorCommons requires an agent's selectedModel: 2.2.1 and 2.6.0 +// validate it, 1.1.0 does not read it (ako/mxcli#2031). A project whose +// version is unknown (no Marketplace version recorded) counts as reading it, +// as every current release does. +func agentEditorReadsSelectedModel(ctx *ExecContext) bool { + modules, err := ctx.Backend.ListModules() + if err != nil { + return true + } + for _, m := range modules { + if m.Name == "AgentEditorCommons" && m.AppStoreVersion != "" { + return compareVersion(m.AppStoreVersion, "2.2.0") >= 0 + } + } + return true +} diff --git a/mdl/executor/validate_agent_left_unset_test.go b/mdl/executor/validate_agent_left_unset_test.go new file mode 100644 index 000000000..e7c8ee1db --- /dev/null +++ b/mdl/executor/validate_agent_left_unset_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/agenteditor" +) + +// leftUnsetFixture is a module M holding a stored agent and consumed MCP +// service, each with or without the setting, next to an AgentEditorCommons +// module of the given Marketplace version. +func leftUnsetWarnings(t *testing.T, commonsVersion string, storedHasValue bool, src string) []string { + t.Helper() + mod := mkModule("M") + commons := mkModule("AgentEditorCommons") + commons.FromAppStore = true + commons.AppStoreVersion = commonsVersion + h := mkHierarchy(mod, commons) + withContainer(h, mod.ID, mod.ID) + + agent := &agenteditor.Agent{BaseElement: model.BaseElement{ID: "agent-1"}, ContainerID: mod.ID, Name: "Stored"} + svc := &agenteditor.ConsumedMCPService{BaseElement: model.BaseElement{ID: "svc-1"}, ContainerID: mod.ID, Name: "StoredSvc"} + if storedHasValue { + agent.SelectedModel = &agenteditor.SelectedModel{ID: "gpt-4o", Name: "gpt-4o"} + svc.Endpoint = &agenteditor.DocRef{DocumentID: "c-1", QualifiedName: "M.Url"} + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod, commons}, nil }, + ListAgentEditorAgentsFunc: func() ([]*agenteditor.Agent, error) { + return []*agenteditor.Agent{agent}, nil + }, + ListAgentEditorConsumedMCPServicesFunc: func() ([]*agenteditor.ConsumedMCPService, error) { + return []*agenteditor.ConsumedMCPService{svc}, nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + sc := newScriptContext() + warnAgentDocumentsLeftUnset(ctx, parseMDL(t, src), sc) + return sc.warnings +} + +func assertLeftUnset(t *testing.T, got []string, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d warnings, want %d:\n %s", len(got), len(want), strings.Join(got, "\n ")) + } + for i, w := range want { + if !strings.Contains(got[i], w) { + t.Errorf("warning %d = %q, want it to contain %q", i, got[i], w) + } + } +} + +const ( + newAgent = `create or modify agent M.New ( UsageType: Task, SystemPrompt: 'x', UserPrompt: 'y' );` + newSvc = `create or modify consumed mcp service M.NewSvc ( ProtocolVersion: v2025_03_26, Version: '1.0' );` +) + +// ako/mxcli#2089: MDL-AGENT02/03 skipped every create or modify, which is how +// project scripts and describe create a document, so a new agent with no +// selected model and a new service with no endpoint were never flagged. +func TestLeftUnset_NewDocumentsWarn(t *testing.T) { + got := leftUnsetWarnings(t, "2.6.0", true, "mdl 1;\n"+newAgent+"\n"+newSvc) + assertLeftUnset(t, got, "MDL-AGENT02: agent M.New", "MDL-AGENT03: consumed mcp service M.NewSvc") +} + +// A stored document that lacks the setting stays without it when the +// statement leaves the key out. +func TestLeftUnset_StoredWithoutValueWarns(t *testing.T) { + got := leftUnsetWarnings(t, "2.6.0", false, `mdl 1; +create or modify agent M.Stored ( UsageType: Task, SystemPrompt: 'x', UserPrompt: 'y' ); +create or modify consumed mcp service M.StoredSvc ( ProtocolVersion: v2025_03_26, Version: '1.0' );`) + assertLeftUnset(t, got, "MDL-AGENT02: agent M.Stored", "MDL-AGENT03: consumed mcp service M.StoredSvc") +} + +// Controls: a stored value is kept, a statement that sets one is fine, and a +// plain create is MDL-AGENT02/03's own (static) case, not reported twice. +func TestLeftUnset_Controls(t *testing.T) { + assertLeftUnset(t, leftUnsetWarnings(t, "2.6.0", true, `mdl 1; +create or modify agent M.Stored ( UsageType: Task, SystemPrompt: 'x', UserPrompt: 'y' ); +create or modify consumed mcp service M.StoredSvc ( ProtocolVersion: v2025_03_26, Version: '1.0' ); +create or modify agent M.Set ( UsageType: Task, SelectedModel: 'gpt-4o', SystemPrompt: 'x', UserPrompt: 'y' ); +create or modify consumed mcp service M.SetSvc ( Endpoint: @M.Url, ProtocolVersion: v2025_03_26, Version: '1.0' ); +create agent M.Plain ( UsageType: Task, SystemPrompt: 'x', UserPrompt: 'y' );`)) +} + +// The script's own statements decide what is stored by the time it runs: a +// drop leaves nothing to keep, an earlier create or alter that sets the value +// does, and SelectedModel: ” clears it. +func TestLeftUnset_ScriptOrder(t *testing.T) { + assertLeftUnset(t, leftUnsetWarnings(t, "2.6.0", true, `mdl 1; +drop agent M.Stored; +create or modify agent M.Stored ( UsageType: Task, SystemPrompt: 'x', UserPrompt: 'y' );`), + "MDL-AGENT02: agent M.Stored") + assertLeftUnset(t, leftUnsetWarnings(t, "2.6.0", false, `mdl 1; +create agent M.New ( UsageType: Task, SelectedModel: 'gpt-4o', SystemPrompt: 'x', UserPrompt: 'y' ); +create or modify agent M.New ( UsageType: Task, SystemPrompt: 'z', UserPrompt: 'y' ); +alter agent M.Stored set SelectedModel = 'gpt-4o'; +create or modify agent M.Stored ( UsageType: Task, SystemPrompt: 'x', UserPrompt: 'y' );`)) + assertLeftUnset(t, leftUnsetWarnings(t, "2.6.0", true, `mdl 1; +create or modify agent M.Stored ( UsageType: Task, SelectedModel: '', SystemPrompt: 'x', UserPrompt: 'y' );`), + "MDL-AGENT02: agent M.Stored") +} + +// Agent Editor before 2.2 does not read selectedModel (measured in +// AgentEditorCommons 1.1.0), so an agent without one is fine there; an +// endpoint is read by every version. +func TestLeftUnset_AgentEditorBefore22(t *testing.T) { + got := leftUnsetWarnings(t, "1.1.0", true, "mdl 1;\n"+newAgent+"\n"+newSvc) + assertLeftUnset(t, got, "MDL-AGENT03: consumed mcp service M.NewSvc") +} diff --git a/mdl/executor/validate_agent_selected_model.go b/mdl/executor/validate_agent_selected_model.go index 0d2e4d0d8..5008267a5 100644 --- a/mdl/executor/validate_agent_selected_model.go +++ b/mdl/executor/validate_agent_selected_model.go @@ -17,8 +17,8 @@ import ( // deployed model where Model = SelectedModelID (measured in AgentEditorCommons // 2.2.1 and 2.6.0; 1.1.0 does not read it). The refusal comes at startup, from // ASU_AgentEditor, and it stops the import of every agent document, not just -// this one. A create or modify is not flagged: left out, it keeps the stored -// selected model. +// this one. A create or modify is left to warnAgentDocumentsLeftUnset, which +// can see whether there is a stored selected model to keep (#2089). const agentSelectedModelRule = "MDL-AGENT02" // ValidateAgentSelectedModel warns on a create agent with no SelectedModel. diff --git a/mdl/executor/validate_consumed_mcp_endpoint.go b/mdl/executor/validate_consumed_mcp_endpoint.go index a8909537f..639337a76 100644 --- a/mdl/executor/validate_consumed_mcp_endpoint.go +++ b/mdl/executor/validate_consumed_mcp_endpoint.go @@ -17,8 +17,9 @@ import ( // found for the local configuration for the constant" and skips the service: // an agent that uses it gets none of its tools. Measured in AgentEditorCommons // 1.1.0, 2.2.1 and 2.6.0, which all map endpoint/qualifiedName to -// MCPEndpointConstant. A create or modify is not flagged: left out, it keeps -// the stored endpoint. +// MCPEndpointConstant. A create or modify is left to +// warnAgentDocumentsLeftUnset, which can see whether there is a stored +// endpoint to keep (#2089). const consumedMCPEndpointRule = "MDL-AGENT03" // ValidateConsumedMCPEndpoint warns on a create consumed mcp service with no diff --git a/mdl/linter/context_catalog_tables.go b/mdl/linter/context_catalog_tables.go index bc3ab3585..f3a55f452 100644 --- a/mdl/linter/context_catalog_tables.go +++ b/mdl/linter/context_catalog_tables.go @@ -597,3 +597,68 @@ func (ctx *LintContext) ConstantValues() iter.Seq[ConstantValue] { } } } + +// AgentDocument is an agent or consumed MCP service the Agent Editor stores, +// with the one setting its import cannot do without (ako/mxcli#2089): an +// agent's selected model id, a consumed MCP service's endpoint constant. +type AgentDocument struct { + QualifiedName string + ModuleName string + Setting string // empty when the document has none +} + +// Agents iterates the agents outside System and the Marketplace, with their +// selected model id. +func (ctx *LintContext) Agents() iter.Seq[AgentDocument] { + return ctx.agentDocuments("Agents", "agents", "SelectedModel") +} + +// ConsumedMCPServices iterates the consumed MCP services outside System and +// the Marketplace, with their endpoint constant. +func (ctx *LintContext) ConsumedMCPServices() iter.Seq[AgentDocument] { + return ctx.agentDocuments("ConsumedMCPServices", "consumed_mcp_services", "Endpoint") +} + +func (ctx *LintContext) agentDocuments(what, table, column string) iter.Seq[AgentDocument] { + return func(yield func(AgentDocument) bool) { + rows, err := ctx.db.Query(fmt.Sprintf(` + SELECT d.QualifiedName, d.ModuleName, COALESCE(d.%s, '') + FROM %s d + LEFT JOIN modules m ON m.Name = d.ModuleName + WHERE %s + ORDER BY d.QualifiedName + `, column, table, notPlatformModule("m"))) + if err != nil { + ctx.recordQueryError(what, err) + return + } + defer rows.Close() + + for rows.Next() { + var d AgentDocument + if err := rows.Scan(&d.QualifiedName, &d.ModuleName, &d.Setting); err != nil { + ctx.recordQueryError(what+" (row scan)", err) + continue + } + if ctx.IsExcluded(d.ModuleName) { + continue + } + if !yield(d) { + return + } + } + } +} + +// MarketplaceModuleVersion is the Marketplace version of the named module, +// or "" when the project has no such module or it records none. +func (ctx *LintContext) MarketplaceModuleVersion(name string) string { + var v sql.NullString + if err := ctx.db.QueryRow(`SELECT AppStoreVersion FROM modules WHERE Name = ?`, name).Scan(&v); err != nil { + if err != sql.ErrNoRows { + ctx.recordQueryError("MarketplaceModuleVersion", err) + } + return "" + } + return v.String +} diff --git a/mdl/linter/rules/agent_documents.go b/mdl/linter/rules/agent_documents.go new file mode 100644 index 000000000..e4103b649 --- /dev/null +++ b/mdl/linter/rules/agent_documents.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "strconv" + "strings" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// AgentWithoutSelectedModelRule reports a stored agent with no selected model +// (ako/mxcli#2089, #2031). +// +// Agent Editor 2.2+ imports an agent only with one: AgentFromModel_Validate +// requires SelectedModelID (measured in AgentEditorCommons 2.2.1 and 2.6.0; +// 1.1.0 does not read it). The refusal comes at startup, from ASU_AgentEditor, +// and stops the import of every agent document. `mx check` and the build are +// green. `mxcli check` warns as MDL-AGENT02 when a script writes one; this rule +// finds the ones a project already holds, whoever wrote them. +type AgentWithoutSelectedModelRule struct{} + +// NewAgentWithoutSelectedModelRule creates the rule. +func NewAgentWithoutSelectedModelRule() *AgentWithoutSelectedModelRule { + return &AgentWithoutSelectedModelRule{} +} + +func (r *AgentWithoutSelectedModelRule) ID() string { return "MPR014" } +func (r *AgentWithoutSelectedModelRule) Name() string { return "AgentWithoutSelectedModel" } +func (r *AgentWithoutSelectedModelRule) Category() string { return "correctness" } +func (r *AgentWithoutSelectedModelRule) DefaultSeverity() linter.Severity { + return linter.SeverityWarning +} + +func (r *AgentWithoutSelectedModelRule) Description() string { + return "Checks for agents with no selected model, which Agent Editor 2.2+ refuses to import at startup, stopping the import of every agent" +} + +// Check reports each agent without a selected model, unless the project's +// AgentEditorCommons predates 2.2 and does not read it. +func (r *AgentWithoutSelectedModelRule) Check(ctx *linter.LintContext) []linter.Violation { + if v := ctx.MarketplaceModuleVersion("AgentEditorCommons"); v != "" && versionBefore(v, 2, 2) { + return nil + } + var out []linter.Violation + for a := range ctx.Agents() { + if a.Setting != "" { + continue + } + out = append(out, linter.Violation{ + RuleID: r.ID(), + Severity: r.DefaultSeverity(), + Message: fmt.Sprintf("agent %s has no selected model: Agent Editor 2.2+ refuses to import it at startup "+ + "(\"Model with name is referenced but could not be found\"), and that stops the import of every agent", a.QualifiedName), + Location: linter.Location{Module: a.ModuleName, DocumentType: "agent", DocumentName: a.QualifiedName}, + Suggestion: fmt.Sprintf("alter agent %s set SelectedModel = '', or pick the model in the Agent Editor", a.QualifiedName), + }) + } + return out +} + +// ConsumedMCPServiceWithoutEndpointRule reports a stored consumed MCP service +// with no endpoint constant (ako/mxcli#2089, #2060). +// +// AgentEditorCommons' ConsumedMCPService_CreateUpdateFromModel skips a service +// whose endpoint constant has no value ("No value could be found for the local +// configuration for the constant"), so an agent that uses it gets none of its +// tools. Measured in AgentEditorCommons 1.1.0, 2.2.1 and 2.6.0. `mxcli check` +// warns as MDL-AGENT03 when a script writes one. +type ConsumedMCPServiceWithoutEndpointRule struct{} + +// NewConsumedMCPServiceWithoutEndpointRule creates the rule. +func NewConsumedMCPServiceWithoutEndpointRule() *ConsumedMCPServiceWithoutEndpointRule { + return &ConsumedMCPServiceWithoutEndpointRule{} +} + +func (r *ConsumedMCPServiceWithoutEndpointRule) ID() string { return "MPR015" } +func (r *ConsumedMCPServiceWithoutEndpointRule) Name() string { + return "ConsumedMCPServiceWithoutEndpoint" +} +func (r *ConsumedMCPServiceWithoutEndpointRule) Category() string { + return "correctness" +} +func (r *ConsumedMCPServiceWithoutEndpointRule) DefaultSeverity() linter.Severity { + return linter.SeverityWarning +} + +func (r *ConsumedMCPServiceWithoutEndpointRule) Description() string { + return "Checks for consumed MCP services with no endpoint constant, which Agent Editor skips at startup, so they connect nowhere" +} + +// Check reports each consumed MCP service without an endpoint. +func (r *ConsumedMCPServiceWithoutEndpointRule) Check(ctx *linter.LintContext) []linter.Violation { + var out []linter.Violation + for s := range ctx.ConsumedMCPServices() { + if s.Setting != "" { + continue + } + out = append(out, linter.Violation{ + RuleID: r.ID(), + Severity: r.DefaultSeverity(), + Message: fmt.Sprintf("consumed mcp service %s has no endpoint: Agent Editor skips it at startup "+ + "(\"No value could be found for the local configuration for the constant\"), so it connects nowhere", s.QualifiedName), + Location: linter.Location{Module: s.ModuleName, DocumentType: "consumed mcp service", DocumentName: s.QualifiedName}, + Suggestion: fmt.Sprintf("alter consumed mcp service %s set Endpoint = @Module.UrlConstant, a String constant holding the server URL", s.QualifiedName), + }) + } + return out +} + +// versionBefore reports whether a dotted version is before major.minor. +func versionBefore(v string, major, minor int) bool { + parts := strings.SplitN(v, ".", 3) + maj, _ := strconv.Atoi(parts[0]) + mn := 0 + if len(parts) > 1 { + mn, _ = strconv.Atoi(parts[1]) + } + return maj < major || (maj == major && mn < minor) +} diff --git a/mdl/linter/rules/agent_documents_test.go b/mdl/linter/rules/agent_documents_test.go new file mode 100644 index 000000000..c524376cb --- /dev/null +++ b/mdl/linter/rules/agent_documents_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "database/sql" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// agentDocsDB is a catalog with module M (the user's), a Marketplace module +// Lib, and an AgentEditorCommons of the given version ("" for none). +func agentDocsDB(t *testing.T, commonsVersion string) catalog.CatalogDB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + for _, q := range []string{ + `CREATE TABLE modules (Id TEXT, Name TEXT PRIMARY KEY, Source TEXT, AppStoreVersion TEXT)`, + `INSERT INTO modules VALUES ('m', 'M', '', ''), ('lib', 'Lib', 'AppStore', '1.0.0')`, + `CREATE TABLE agents (QualifiedName TEXT, ModuleName TEXT, SelectedModel TEXT)`, + `INSERT INTO agents VALUES ('M.WithModel', 'M', 'gpt-4o'), ('M.NoModel', 'M', NULL), + ('M.EmptyModel', 'M', ''), ('Lib.NoModel', 'Lib', NULL)`, + `CREATE TABLE consumed_mcp_services (QualifiedName TEXT, ModuleName TEXT, Endpoint TEXT)`, + `INSERT INTO consumed_mcp_services VALUES ('M.WithEndpoint', 'M', 'M.Url'), ('M.NoEndpoint', 'M', ''), + ('Lib.NoEndpoint', 'Lib', '')`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + if commonsVersion != "" { + if _, err := db.Exec(`INSERT INTO modules VALUES ('aec', 'AgentEditorCommons', 'AppStore', ?)`, commonsVersion); err != nil { + t.Fatal(err) + } + } + return catalog.WrapSqlDB(db) +} + +func violationNames(vs []linter.Violation) string { + var out []string + for _, v := range vs { + out = append(out, v.RuleID+" "+v.Location.DocumentName) + } + return strings.Join(out, "|") +} + +// ako/mxcli#2089: nothing flagged a stored agent without a selected model or a +// consumed MCP service without an endpoint, whoever wrote it. A Marketplace +// module's documents are its vendor's, as for every other rule. +func TestAgentWithoutSelectedModelRule(t *testing.T) { + ctx := linter.NewLintContextFromDB(agentDocsDB(t, "2.6.0")) + if got, want := violationNames(NewAgentWithoutSelectedModelRule().Check(ctx)), "MPR014 M.EmptyModel|MPR014 M.NoModel"; got != want { + t.Errorf("violations = %q, want %q", got, want) + } +} + +// Agent Editor 1.x does not read selectedModel (AgentEditorCommons 1.1.0), so +// an agent there is fine without one. Without the module the version is +// unknown and the rule applies, as every current release reads it. +func TestAgentWithoutSelectedModelRule_Version(t *testing.T) { + if got := violationNames(NewAgentWithoutSelectedModelRule().Check(linter.NewLintContextFromDB(agentDocsDB(t, "1.1.0")))); got != "" { + t.Errorf("AgentEditorCommons 1.1.0: violations = %q, want none", got) + } + if got := violationNames(NewAgentWithoutSelectedModelRule().Check(linter.NewLintContextFromDB(agentDocsDB(t, "")))); got == "" { + t.Error("no AgentEditorCommons version: want the rule to apply") + } +} + +func TestConsumedMCPServiceWithoutEndpointRule(t *testing.T) { + // Every measured Agent Editor reads the endpoint, 1.1.0 included. + ctx := linter.NewLintContextFromDB(agentDocsDB(t, "1.1.0")) + if got, want := violationNames(NewConsumedMCPServiceWithoutEndpointRule().Check(ctx)), "MPR015 M.NoEndpoint"; got != want { + t.Errorf("violations = %q, want %q", got, want) + } +} From a73125a0201178e7815ba21ce42b0ae1ffd238db Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 16:54:37 +0100 Subject: [PATCH 05/16] docs(skills): genai-runtime, the runtime side of Agents Kit 2 (#2068) Registering a Mendix Cloud GenAI key or an OpenAI-compatible endpoint, seeding AgentCommons agents from JSON, resuming Conversational UI chats, loading PgVector and Mendix Cloud knowledge bases, and traces, with the gotchas no check sees: the encryption key both the OpenAI and PgVector setup need, the vector extension only the UI's Test button creates, Agent_GetCreate_Json matching on Title only, the 60-120 s indexing delay, the chat that collapses outside its page, and StoreTraces off by default. Signatures and behaviour read from the Agent Builder Starter App's modules (AgentCommons 4.4.0, ConversationalUI 7.4.0, GenAICommons 7.3.0, MxGenAIConnector 7.4.0, OpenAIConnector 9.4.0, PgVectorKnowledgeBase 7.1.0). The examples pass check --references there and build with 0 errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82ttaGzay5CwiC2V7cf --- .claude/skills/mendix/agents/SKILL.md | 7 +- .claude/skills/mendix/genai-runtime/SKILL.md | 312 +++++++++++++++++++ CHANGELOG.md | 1 + 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/mendix/genai-runtime/SKILL.md diff --git a/.claude/skills/mendix/agents/SKILL.md b/.claude/skills/mendix/agents/SKILL.md index d02ce4bb9..09eab2ae7 100644 --- a/.claude/skills/mendix/agents/SKILL.md +++ b/.claude/skills/mendix/agents/SKILL.md @@ -15,8 +15,8 @@ description: "Author Mendix AI agent documents in MDL — Model, Knowledge Base, | 193-219 | Altering existing documents | | 220-268 | Gotchas | | 269-291 | Common Patterns | -| 292-356 | Calling Agents from Microflows | -| 357-359 | AI agent documents | +| 292-359 | Calling Agents from Microflows | +| 360-362 | AI agent documents | # Agents @@ -291,6 +291,9 @@ list agents in module; ## Calling Agents from Microflows +Registering models, seeding runtime agents from JSON, chat pages and loading +knowledge bases at runtime are in the `genai-runtime` skill. + Studio Pro's *Call Agent* and *New Chat for Agent* toolbox actions are **Java actions exposed as microflow actions**, so `call java action` writes exactly the activity the toolbox inserts. There is no separate `call agent` statement, and diff --git a/.claude/skills/mendix/genai-runtime/SKILL.md b/.claude/skills/mendix/genai-runtime/SKILL.md new file mode 100644 index 000000000..f55a787a5 --- /dev/null +++ b/.claude/skills/mendix/genai-runtime/SKILL.md @@ -0,0 +1,312 @@ +--- +name: genai-runtime +description: "Set up the runtime side of a Mendix GenAI app (Agents Kit 2): register a Mendix Cloud GenAI key or an OpenAI-compatible endpoint, seed AgentCommons agents from JSON, open and resume Conversational UI chats, load PgVector or Mendix Cloud knowledge bases, and turn on traces. Covers the startup-code gotchas (the encryption key, the vector extension, indexing delay, the collapsed chat). For the Agent Editor documents themselves, see the agents skill." +--- + +## Find it fast + +`sed -n ',p' SKILL.md` one section rather than reading the file; +`./mxcli syntax ` answers *how do I write this* for a fraction of either. + +| lines | section | +|---|---| +| 37-47 | Startup code must not throw | +| 48-131 | Models | +| 132-174 | Runtime agents (AgentCommons) | +| 175-231 | Chat (Conversational UI) | +| 232-273 | Knowledge bases | +| 274-285 | Agents attached to knowledge bases | +| 286-300 | Traces and token usage | +| 301-312 | Gotchas, in one place | + + +# GenAI at Runtime (Agents Kit 2) + +The `agents` skill covers the **documents** (Model, Knowledge Base, Consumed MCP +Service, Agent) and calling an agent from a microflow. This skill covers what an +app does with the Marketplace modules **at runtime**: the startup microflows that +register models, seed agents and load knowledge bases, and the chat pages. All of +it is ordinary microflow logic, so `describe microflow`, `check --references` and +`exec` apply as usual. + +Measured against the Agent Builder Starter App modules: AgentCommons 4.4.0, +ConversationalUI 7.4.0, GenAICommons 7.3.0, MxGenAIConnector 7.4.0, +OpenAIConnector 9.4.0, PgVectorKnowledgeBase 7.1.0 (Mendix 11.16). Check the +installed versions with `list modules` before copying a signature. + +## Startup code must not throw + +Every setup step below that stores a secret (an OpenAI key, a PgVector password) +goes through `Encryption.Encrypt`, which **raises an error when +`Encryption.EncryptionKey` is empty or not 32 characters**. The constant's +default is empty, so a run configuration without it (a new environment, or +`mxcli run --local` / `test --local` on a model that sets it only in `Default`) +throws there. Unhandled in an after-startup microflow, that stops the runtime. +Give each such call an `on error` handler, as below, and set the key per +environment. + +## Models + +### Mendix Cloud GenAI + +The key string from the Mendix Cloud GenAI portal registers the resource and its +models in one call. It overwrites a configuration with the same ID, so calling it +on every startup is safe: + +```sql +mdl 1; + +create constant MyModule.GenAIKey ( Type: String, DefaultValue: '' ); + +create microflow MyModule.GenAI_Register () +returns Boolean +begin + $Configuration = call microflow MxGenAIConnector.Configuration_RegisterByString(ConfigurationStringImport = @MyModule.GenAIKey); + if $Configuration = empty then + log error node 'GenAI' 'MyModule.GenAIKey holds no valid Mendix Cloud GenAI key'; + return false; + end if; + return true; +end; +``` + +### An OpenAI-compatible endpoint + +The plain key goes on the non-persistent `OpenAIConnector.ApiKey` helper, never +on `Configuration/ApiKey` (which holds the encrypted value); +`Configuration_Commit` encrypts it. Set `IsNativeOpenAI = false` for an API that +only imitates OpenAI (Mistral, a local server). `OpenAIDeployedModel_Create` +already sets the model's execution microflow for its modality, so +`OpenAIDeployedModel_SetMicroflow` (the UI's path) is not needed after it. + +```sql +mdl 1; + +create constant MyModule.LlmEndpoint ( Type: String, DefaultValue: 'https://api.openai.com/v1' ); +create constant MyModule.LlmApiKey ( Type: String, DefaultValue: '' ); + +create microflow MyModule.Llm_Register () +returns Boolean +begin + retrieve $Existing from OpenAIConnector.Configuration + where DisplayName = 'Assistant LLM' + first; + if $Existing != empty then + return true; + end if; + $Configuration = call microflow OpenAIConnector.Configuration_Create(); + change $Configuration (DisplayName = 'Assistant LLM', ApiType = OpenAIConnector.ENUM_ApiType.OpenAI, Endpoint = @MyModule.LlmEndpoint, IsNativeOpenAI = true); + $ApiKey = call microflow OpenAIConnector.ApiKey_GetCreate_Configuration(Configuration = $Configuration); + change $ApiKey (ApiKey = @MyModule.LlmApiKey); + call microflow OpenAIConnector.Configuration_Commit(Configuration = $Configuration) + on error without rollback begin + log error node 'GenAI' 'Could not store the LLM key: is Encryption.EncryptionKey set?'; + return false; + end error; + $InputModalities = create list of GenAICommons.InputModality; + $Model = call microflow OpenAIConnector.OpenAIDeployedModel_Create( + Configuration = $Configuration, + Model = 'gpt-4o-mini', + Architecture = 'OpenAI', + OutputModality = GenAICommons.ENUM_ModelModality.Text, + ApiVersion = empty, + DisplayName = 'GPT-4o mini', + SupportsSystemPrompt = GenAICommons.ENUM_ModelSupport._True, + SupportsFunctionCalling = GenAICommons.ENUM_ModelSupport._True, + SupportsConversationHistory = GenAICommons.ENUM_ModelSupport._True, + InputModalityList = $InputModalities, + SupportsStreaming = GenAICommons.ENUM_ModelSupport._True + ); + if $Model = empty then + return false; + end if; + call microflow GenAICommons.DeployedModel_Commit(DeployedModel = $Model); + return true; +end; +``` + +An embeddings model is the same call with +`OutputModality = GenAICommons.ENUM_ModelModality.Embeddings`. Video, Audio, +Document and Other are refused (logged, `empty` returned). + +## Runtime agents (AgentCommons) + +A runtime agent is an `AgentCommons.Agent` object, not a document. Seed one from +the JSON the Agents page exports, with `AgentCommons.Agent_GetCreate_Json`, the +way the module's own templates do (`AgentCommons.ProductDescriptionGeneratorAgent` +is a complete example: `describe microflow` it). + +```sql +$Agent = call microflow AgentCommons.Agent_GetCreate_Json(AgentTitle = 'Support agent', AgentJson = @MyModule.SupportAgentJson); +``` + +What the import does, which decides how to seed: + +- **It matches on `Title` only, and then returns the stored agent without + reading the JSON.** It is idempotent, but a changed JSON never reaches an agent + that exists. To roll out a new prompt, change the title, or delete the agent + first, or use an Agent Editor document instead (the `agents` skill), which + `ASU_AgentEditor` re-imports on every start. +- **The models and knowledge bases must exist, committed, before the call.** Each + version's `DeployedModel {Architecture, Model}` is looked up among the existing + `GenAICommons.DeployedModel` objects (`Architecture` `Mendix Cloud` for Mendix + Cloud GenAI); a knowledge base's `ConsumedKnowledgeBase {DisplayName, + Architecture}` the same way (`Architecture` `PgVector` for PgVector). A miss + leaves the version without one; chat then fails with "Select a model on the + agent version in use". So register models first, then seed agents. +- `Version_InUse {UUID}` picks the version the agent runs. +- `UUID` and `Title` are required; without them the import logs and returns empty. +- A tool's `UserAccessApproval` takes the `GenAICommons.ENUM_UserAccessApproval` + keys: `UserConfirmationRequired`, `VisibleForUser` or `HiddenForUser`. (The + module's sample JSON writes `Hidden`, which is not one.) + +The JSON's keys: `Title`, `Description`, `UsageType`, `UUID`, `Entity`, +`DeployedModel`, `Version_InUse`, `Variables[{Key}]`, `TestCases`, and +`Versions[]` with `SystemPrompt`, `UserPrompt`, `UUID`, the generation settings, +`DeployedModel`, `Tools[{Name, Description, Microflow, UserAccessApproval, +IsEnabled, UUID}]`, `MCPs[]`, `SingleMCPTools[]` and `KnowledgeBases[{Name, +Description, Collection, MaxNumberOfResults, MinimumSimilarity, IsEnabled, +ConsumedKnowledgeBase{DisplayName, Architecture}}]`. Export one from the Agents +page rather than writing it from scratch. + +Calling it is the `agents` skill's *Calling Agents from Microflows* +(`Agent_Call_WithoutHistory`, `Agent_Call_WithHistory`). + +## Chat (Conversational UI) + +`AgentCommons.ChatContext_Create_ForAgent` creates a `ConversationalUI.ChatContext` +for the agent. It needs the agent's in-use version to have a model, **returns +empty instead of throwing** on any problem, and does not commit. Its action +microflow takes one `ChatContext` and returns Boolean; +`ConversationalUI.ChatContext_ChatWithHistory_ActionMicroflow_Agent_Runtime` is +the ready-made one. + +`ChatContext` is persistent, owned by the user who created it (its `User` access +is limited to `System.owner`). Creating one on every page visit fills the user's +history with empty chats. Resume instead; this is the starter app's +`MyFirstSupportAgent.ChatContext_GetCreate`, which reuses the user's chat that +has no messages yet: + +```sql +mdl 1; + +create microflow MyModule.SupportChat_GetCreate ( + $Agent: AgentCommons.Agent +) +returns ConversationalUI.ChatContext +begin + retrieve $ChatContext from ConversationalUI.ChatContext + where [System.owner = $currentUser] + [not(ConversationalUI.Message_ChatContext/ConversationalUI.Message)] + [ConversationalUI.ChatContext_Agent = $Agent] + first; + if $ChatContext != empty then + return $ChatContext; + end if; + $NewChatContext = call java action AgentCommons.ChatContext_Create_ForAgent( + Agent = $Agent, + ActionMicroflow = 'ConversationalUI.ChatContext_ChatWithHistory_ActionMicroflow_Agent_Runtime', + ContextObject = empty, + OverwritingDeployedModel = empty + ); + return $NewChatContext; +end; +``` + +- **Topics.** `ConversationalUI.ChatContext_Preprocessing` sets the chat's + `Topic` from the first 200 characters of the first prompt, only while the topic + is empty. It changes the object without committing it. +- **History.** `ConversationalUI.Snippet_ChatContext_HistorySideBar` is one row + (the topic and a delete button) for one `ChatContext`. The list around it is + yours: a list view over the user's `ConversationalUI.ChatContext` objects, + calling that snippet per row. +- **The chat collapses outside its page.** `Snippet_ChatContext_ConversationalUI` + is a flex column whose message list is `flex: 1 1 0`. It needs a parent with a + real height, which `ConversationalUI.ConversationalUI_FullScreenChat` gives it + (`chat-page--fullheight`). Placed in an ordinary container, the message list has + zero height and only the input box shows. The messages are still in the DOM, so + a text-based check passes; check it with a screenshot (`verify-in-runtime`). + Open the full-screen page with the chat as its parameter, or give the parent a + height. + +## Knowledge bases + +Both connectors take a `GenAICommons.ChunkCollection`. Build it with +`GenAICommons.ChunkCollection_Create` and add one chunk per source object with +the Java action `GenAICommons.ChunkCollection_AddKnowledgeBaseChunk` +(`InputText`, `HumanReadableID`, `MxObject`, optional `MetadataCollection`). +Pass the `MxObject`: `Replace` deletes and re-inserts by it, and PgVector +refuses a chunk without one. Knowledge base and collection names may not contain +spaces (both connectors log and return empty). + +### PgVector + +1. **The database needs the `vector` extension.** The module creates it + (`CREATE EXTENSION IF NOT EXISTS vector`) only from the *Test* button on its + configuration page. Seeding from code does not, so install it on the server, + or give the database user the right to create it and press *Test* once. +2. **Configuration.** Create a `PgVectorKnowledgeBase.DatabaseConfiguration` with + `DisplayName`, `JdbcURL` and `Username`. Set the password on the helper from + `PgVectorKnowledgeBase.DatabasePassword_GetCreate` (`$Helper/Password`), then + call `PgVectorKnowledgeBase.DatabaseConfiguration_Commit`. That encrypts the + password (**the encryption-key rule above applies**), sets `Architecture` to + `PgVector` and the retrieval microflows, and commits. Its `DisplayName` is what + an agent's JSON names in `ConsumedKnowledgeBase`. +3. **Load.** Embed first, then store: `GenAICommons.Embeddings_ChunkCollection` + (a Java action; `DeployedModel` must be an Embeddings model, and it writes the + vectors onto the chunks), then + `PgVectorKnowledgeBase.PgVectorKnowledgeBase_GetOrCreate($DatabaseConfiguration, + $KnowledgeBaseName)`, then `PgVectorKnowledgeBase.ChunkCollection_Replace`. + `ChunkCollection_Replace` does not embed, and returns `false` (logged, not + thrown) for an empty collection, a chunk without `MxObject`, an `InputText` + over 4000 characters, or an unconfigured database. Check the result. + +### Mendix Cloud GenAI + +`MxGenAIConnector.Collection_GetCreate($MxCloudKnowledgeBaseResource, +$CollectionName)` returns the collection; `MxGenAIConnector.ChunkCollection_Embed_Replace` +embeds with the resource's own model and stores. **Chunks become searchable 60 +to 120 seconds later** (the module documents it; measured on a live resource: +none at 30 s, found at about 90 s), and replacing the same objects within that +window can leave the knowledge base out of sync. A test that loads and then +queries at once finds nothing: wait, or poll. + +## Agents attached to knowledge bases + +On a runtime agent, a knowledge base is an `AgentCommons.KnowledgeBase` object +on the version (association `AgentCommons.KnowledgeBase_Version`), pointing at the +consumed knowledge base through the association +`AgentCommons.KnowledgeBase_ConsumedKnowledgeBase`. `CollectionIdentifier` holds +the collection or knowledge base name. `AgentCommons.KnowledgeBase_Create($Version)` +starts one (enabled, with a UUID, not committed); `KnowledgeBase_Save` commits. +Its `Name` must match `[a-zA-Z_][a-zA-Z0-9_]+`, at most 64 characters, and not +clash with an enabled tool on the same version. Seeding through the JSON's +`KnowledgeBases[]` does the same. + +## Traces and token usage + +- **`GenAICommons.StoreTraces` defaults to false**, so the Traces page stays empty + until a run configuration sets it. Setting it also stores token usage. + `GenAICommons.StoreUsageMetrics` (default true) records usage for chat + completions and embeddings only. +- `Trace_CleanUpAfterDays` and `Usage_CleanUpAfterDays` default to 0, never + delete; the scheduled event `GenAICommons.ScE_Usage_Cleanup` applies them. +- The pages are `ConversationalUI.Trace_Overview` (module role + `ConversationalUI.TraceMonitoring`), `ConversationalUI.TokenConsumptionMonitor` + (`ConversationalUI.UsageMonitoring`) and `AgentCommons.Agent_Overview`. They + size themselves in `vw` (91 to 95), so beside an open sidebar their right edge + is clipped. Give them a layout without the sidebar, or override the width in + the theme. + +## Gotchas, in one place + +| Symptom | Cause | +|---|---| +| Runtime stops at startup after a model or PgVector setup | `Encryption.EncryptionKey` empty in that run configuration; the commit raises | +| A changed agent JSON has no effect | `Agent_GetCreate_Json` returns the stored agent by `Title` | +| "Select a model on the agent version in use" | The JSON's `DeployedModel` matched no committed `GenAICommons.DeployedModel` at import | +| A new chat per visit, history full of empty chats | `ChatContext` is persistent; resume one (above) | +| Only the chat's input box shows | The chat snippet's parent has no height | +| PgVector load returns false | No `MxObject` on a chunk, text over 4000 characters, not embedded first, or no `vector` extension | +| Mendix Cloud knowledge base finds nothing right after loading | 60 to 120 s indexing delay | +| Traces page empty | `GenAICommons.StoreTraces` is false by default | diff --git a/CHANGELOG.md b/CHANGELOG.md index ff01f0a39..9dad91d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **A `genai-runtime` skill for the runtime side of Agents Kit 2** (ako/mxcli#2068) — registering a Mendix Cloud GenAI key or an OpenAI-compatible endpoint, seeding AgentCommons agents from JSON (matched by `Title` only, so a changed JSON never reaches a stored agent), resuming Conversational UI chats, loading PgVector and Mendix Cloud knowledge bases, and traces. It collects the gotchas that `check` and `mx check` cannot see: the encryption key both the OpenAI and PgVector setup need, the `vector` extension only the UI's Test button creates, the 60–120 s indexing delay, the chat that collapses outside its page, and `StoreTraces` defaulting to false. Its examples pass `check --references` and build with 0 errors against the Agent Builder Starter App's modules. - **Agent prompts in full-text search** (ako/mxcli#2075) — `CATALOG.STRINGS` indexed only an agent's documentation, so `search` could not find its system or user prompt, or the description the model reads for each tool and knowledge base: the most important text in an agentic app. Each is now a row with `ObjectType` `AGENT` and a `StringContext` naming it (`system_prompt`, `user_prompt`, `agent_description`, `tool_description`, `tool_display_title`, `tool_display_description`, `knowledge_base_description`). A published MCP service's tool descriptions and titles are rows of the service too (`mcp_tool_description`, `mcp_tool_title`). Catalog schema 43. - **Agent details in the catalog: `AGENT_TOOLS`, `AGENT_KNOWLEDGE_BASES`, `AGENT_MCP_SERVICES`, `PUBLISHED_MCP_SERVICES`, and each agent's settings** (ako/mxcli#2075) — `CATALOG.AGENTS` and its siblings held a document's name only, so which agents can run a tool without the user's approval, which use a model, or which have streaming on took one `describe agent` per agent. `AGENTS` gains the usage type, model, selected model, tool choice, generation settings, streaming switches (`NULL` when not stored), context entity, variables and tool counts; `AI_MODELS` and `KNOWLEDGE_BASES` the provider and key constant; `CONSUMED_MCP_SERVICES` the protocol version, version and timeout. An agent's tools and knowledge base tools are rows of their own, and a published MCP service is a catalog object whose tool and authentication microflows are `publish` edges, so they are no longer listed in `GRAPH_DEAD_ASSETS`. Catalog schema 42. - **A widget's caption and tooltip in lint rules, and its texts tied to it in every language** (ako/mxcli#1303, mendixlabs/mxcli#1312) — with two buttons on a page a rule could not tell which caption belonged to the one without a confirmation: `widgets()` had no caption, and the `strings()` caption row named the button's inner `Forms$ClientTemplate`, not the button. `widgets()` gains `caption` and `tooltip` in the default language, and `strings()` gains `widget_id`, so a widget's caption, tooltip and confirmation texts can be collected per language. Full catalog; the schema moves to version 40, so a cached catalog is rebuilt once. From d3d552797cd8a2ac8602a06fb1e7053d99bc7445 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:12:44 +0000 Subject: [PATCH 06/16] microflows: splice a change inside a multi-branch case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create or modify microflow` was refused with "replace case $Source: cannot replace the ExclusiveSplit … it has 9 paths, not two" whenever anything differed inside one branch of a case, and with "drop case" when the case sat in one changed run with activities replaced before it. The body diff had shell matches for an if and a loop, but none for an enumeration case, so the case was replaced or dropped as a whole, and the splice only takes out a two-path decision. sameCaseShell matches a case with the same subject and branches, in the same order, and diffs each branch the way an if's are; casePair splits a run of changes around such a case, as loopPair does for a loop. Verified on TestApp: the unfixed binary refuses both shapes; the fixed one splices them, a re-run reports Unchanged, and mx check reports 0 errors. Fixes #2035 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82qrr1WnXxq1eRn7qRJ --- ...ate-or-modify-replaces-multipath-case.json | 1 + CHANGELOG.md | 1 + mdl/executor/cmd_flow_modify.go | 70 ++++++++++++++++ .../cmd_flow_modify_case_shell_1778_test.go | 83 +++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-10-create-or-modify-replaces-multipath-case.json create mode 100644 mdl/executor/cmd_flow_modify_case_shell_1778_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-create-or-modify-replaces-multipath-case.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-create-or-modify-replaces-multipath-case.json new file mode 100644 index 000000000..33f4c9e73 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-create-or-modify-replaces-multipath-case.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-10", "symptom": "ako/mxcli#2035 (mendixlabs/mxcli#1778): `create or modify microflow` was refused with 'replace case $Source: cannot replace the ExclusiveSplit … it has 9 paths, not two' (or 'drop case …') when anything differed inside a branch of a multi-branch case, including a one-activity change, and also when that case sat in one changed run with activities replaced before it.", "cause": "diffFlowBody had shell matches for if (sameIfShell, branches spliced) and loops (sameLoopShell / loopPair), but none for an enumeration case: a case that differed anywhere inside was replaced as a whole — or, in a run with other changes, replaced-first-and-dropped — and the splice only takes out a two-path decision.", "file": "mdl/executor/cmd_flow_modify.go (sameCaseShell, casePair)", "insight": "Each compound statement needs both halves: a shell match for a lone pair (diff its bodies) and a pair finder that splits a run around it, or a change next to it drags it into a replace. Pair branches by position, as describe prints them; a reordered branch is a real split change. Reproduced on TestApp (unfixed binary refuses, fixed splices '1 edited in place', re-run Unchanged, mx check 0 errors). The reporter's other observation — check passing what exec refuses because check reads the catalog's source text (mendixlabs/mxcli#1779) — is a separate cause.", "refs": ["ako/mxcli#2035", "mendixlabs/mxcli#1778", "mendixlabs/mxcli#1779", "ako/mxcli#1694", "ako/mxcli#942"], "test": "mdl/executor/cmd_flow_modify_case_shell_1778_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a23d6934..64ddab2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **`alter page … insert into { row { column { … } } }` appends a row** (ako/mxcli#2071, mendixlabs/mxcli#1791) — it failed with "row needs a name", and with a name with "not a simple container", so a row could only be added by recreating the page. Rows are now built as layout grid rows and appended to the grid, as tab pages are to a tab container (#1215). A row has no stored name, so `insert before/after` cannot place one and says to use `insert into` the grid; `mxcli syntax page.alter` shows the form. +- **`create or modify` of a microflow with a multi-branch `case` splices a change inside a branch instead of refusing** (ako/mxcli#2035, mendixlabs/mxcli#1778) — anything that differed inside one branch, or a change next to the case, made the whole split be replaced or dropped, which the splice refuses for more than two paths ("it has 9 paths, not two"); a long exec then rolled back. A `case` with the same subject and branches is now diffed branch by branch, as an `if` is, and split out of a run of other changes, as a loop is. - **A consumed MCP service written by mxcli connects to its server** (ako/mxcli#2060) — it had no `endpoint` and no credentials microflow, so Agent Editor skipped it at startup and the agents using it got no tools, while `mx check` stayed green. `create`/`alter consumed mcp service` take `Endpoint: @Module.UrlConstant` (a String constant) and `AuthenticationMicroflow: Module.GetHeaders`, `describe` prints them, `create or modify` keeps stored ones, `check` warns on a `create` without an endpoint (MDL-AGENT03), and `check --references` refuses a non-String constant or a credentials microflow MCP Client cannot call (it must take no parameters and return a list of `System.HttpHeader`). The agents skill and syntax help show it (part of ako/mxcli#2067). - **`create agent` writes the selected model, so Agent Editor imports the agent** (ako/mxcli#2031) — Agent Editor 2.2+ refused every mxcli-created agent at startup ("Model with name is referenced but could not be found"), and one refused agent stopped the import of all of them, while `mx check` and the build stayed green. `create`/`alter agent` take `SelectedModel: ''` (and an optional `SelectedModelName`, defaulting to the id), `describe` prints it, and `check` warns on a `create agent` without one (MDL-AGENT02). The skill, syntax help and examples now show it (part of ako/mxcli#2067). - **`move scheduled event M.Nightly` moves the scheduled event when a microflow shares its name** (ako/mxcli#2072, mendixlabs/mxcli#1792) — the move looked the name up regardless of type, met the microflow first and refused with "M.Nightly is a microflow, not a scheduled event". It now picks the document of the type the statement names; a name no document of that type has is still refused, naming what it is. diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index 39533e4c7..83efd248b 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -981,6 +981,20 @@ func (pd *patchDiff) gap(ins []ast.MicroflowStatement, stored []ast.MicroflowSta } return pd.statements(d.ElseBody, s.ElseBody) } + if d, s, ok := sameCaseShell(ins[0], del[0]); ok { + // The same `case` with a change in a branch: splice each branch + // like an if's, rather than replace the split — which the splice + // cannot do for more than two paths (mendixlabs/mxcli#1778). + if err := pd.movedNode(d, s); err != nil { + return err + } + for i := range d.Cases { + if err := pd.statements(d.Cases[i].Body, s.Cases[i].Body); err != nil { + return err + } + } + return pd.statements(d.ElseBody, s.ElseBody) + } if sameHandlerShell(ins[0], del[0]) { return pd.handlerEdit(ins[0], del[0]) } @@ -1008,6 +1022,19 @@ func (pd *patchDiff) gap(ins []ast.MicroflowStatement, stored []ast.MicroflowSta // at the loop, which is diffed in place like a loop on its own, and the // changes on either side of it are runs of their own (ako/mxcli#942, // ako/mxcli#1693). + // A stored `case` declared again with a change in a branch, in a run with + // other changes, is split out the same way: replacing the run would take + // the split out, which the splice cannot do for more than two paths + // (mendixlabs/mxcli#1778). + if i, j, ok := casePair(ins, del); ok && (len(ins) > 1 || len(del) > 1) { + if err := pd.gap(ins[:i], stored, si, si+j); err != nil { + return err + } + if err := pd.gap(ins[i:i+1], stored, si+j, si+j+1); err != nil { + return err + } + return pd.gap(ins[i+1:], stored, si+j+1, sEnd) + } if i, j, ok := loopPair(ins, del); ok { if err := pd.gap(ins[:i], stored, si, si+j); err != nil { return err @@ -1182,6 +1209,36 @@ func sameIfShell(declared, stored ast.MicroflowStatement) (*ast.IfStmt, *ast.IfS return d, s, true } +// sameCaseShell reports whether two statements are the same `case` — the +// same subject, annotations and branches, each branch deciding on the same +// values in the same order, and an else on both or neither — differing at most +// inside its branches and in where the split is drawn. A branch's activities +// are top-level nodes of the stored graph, as an if's are, so each branch is +// spliced like one. +// +// The branches are paired by position, not by value: describe prints them in +// the order the stored flows leave the split, and a script restating the flow +// keeps that order. A reordered or regrouped branch is a change of the split +// itself, which is still replaced (and refused for more than two paths). +func sameCaseShell(declared, stored ast.MicroflowStatement) (*ast.EnumSplitStmt, *ast.EnumSplitStmt, bool) { + d, ok1 := declared.(*ast.EnumSplitStmt) + s, ok2 := stored.(*ast.EnumSplitStmt) + if !ok1 || !ok2 || len(d.Cases) != len(s.Cases) || (len(d.ElseBody) == 0) != (len(s.ElseBody) == 0) { + return nil, nil, false + } + dShell, sShell := *d, *s + dShell.Cases, sShell.Cases = make([]ast.EnumSplitCase, len(d.Cases)), make([]ast.EnumSplitCase, len(s.Cases)) + for i := range d.Cases { + dShell.Cases[i] = ast.EnumSplitCase{Value: d.Cases[i].Value, Values: d.Cases[i].Values} + sShell.Cases[i] = ast.EnumSplitCase{Value: s.Cases[i].Value, Values: s.Cases[i].Values} + } + dShell.ElseBody, sShell.ElseBody = nil, nil + if !sameExceptPositions(&dShell, &sShell) { + return nil, nil, false + } + return d, s, true +} + // ifConditionChanged reports whether two statements are the same `if` but for // its condition or its caption — the same notes, the same else — differing // besides at most inside its branches and in where the split is drawn @@ -1375,6 +1432,19 @@ func loopPair(ins, del []ast.MicroflowStatement) (int, int, bool) { return 0, 0, false } +// casePair finds, in a run of unmatched statements, a declared `case` that is +// a stored one restated (sameCaseShell): its index in ins and in del. +func casePair(ins, del []ast.MicroflowStatement) (int, int, bool) { + for i, d := range ins { + for j, st := range del { + if _, _, ok := sameCaseShell(d, st); ok { + return i, j, true + } + } + } + return 0, 0, false +} + // inLoopBody diffs the body of the stored loop lc (drawn as loop, described // as at) the way the top level is diffed — statements matched by a longest // common subsequence, each run between two matched ones an insert, replace or diff --git a/mdl/executor/cmd_flow_modify_case_shell_1778_test.go b/mdl/executor/cmd_flow_modify_case_shell_1778_test.go new file mode 100644 index 000000000..ae30fd681 --- /dev/null +++ b/mdl/executor/cmd_flow_modify_case_shell_1778_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#2035 (mendixlabs/mxcli#1778): a `create or modify` that changed +// one activity in one branch of a `case` — or that describe and the script +// spelled differently somewhere inside it — was refused: the diff had no shell +// match for a case, as it has for an `if`, so the whole split was replaced, +// and the splice replaces a decision of two paths only ("it has 9 paths, not +// two"). sameCaseShell is the match that lets each branch be spliced instead. + +func caseStmt(subject string, branches map[string]string, order ...string) *ast.EnumSplitStmt { + c := &ast.EnumSplitStmt{Variable: subject} + for _, v := range order { + c.Cases = append(c.Cases, ast.EnumSplitCase{ + Value: v, Values: []string{v}, + Body: []ast.MicroflowStatement{&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Value: branches[v], Kind: ast.LiteralString}}}, + }) + } + return c +} + +func TestSameCaseShell(t *testing.T) { + base := map[string]string{"A": "a", "B": "b", "C": "c"} + changed := map[string]string{"A": "a", "B": "CHANGED", "C": "c"} + for _, tc := range []struct { + name string + declared *ast.EnumSplitStmt + want bool + }{ + {"a change inside one branch is the same shell", caseStmt("Root/Src", changed, "A", "B", "C"), true}, + {"control: another subject", caseStmt("Root/Other", changed, "A", "B", "C"), false}, + {"control: a branch fewer", caseStmt("Root/Src", changed, "A", "B"), false}, + {"control: branches reordered", caseStmt("Root/Src", changed, "B", "A", "C"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + stored := caseStmt("Root/Src", base, "A", "B", "C") + if _, _, got := sameCaseShell(tc.declared, stored); got != tc.want { + t.Errorf("sameCaseShell = %v, want %v", got, tc.want) + } + }) + } + t.Run("control: an else on one side only", func(t *testing.T) { + d, s := caseStmt("Root/Src", base, "A", "B"), caseStmt("Root/Src", base, "A", "B") + d.ElseBody = []ast.MicroflowStatement{&ast.LogStmt{Level: ast.LogInfo}} + if _, _, got := sameCaseShell(d, s); got { + t.Error("an else added counted as the same shell") + } + }) + t.Run("control: an if is not a case", func(t *testing.T) { + if _, _, got := sameCaseShell(&ast.IfStmt{}, caseStmt("Root/Src", base, "A")); got { + t.Error("an if matched a case") + } + }) +} + +// The reported statement changed two activities before the case as well: +// the case then sits in one run with them, which was replaced first-and-drop- +// the-rest, dropping the split. casePair finds the case in the run so it is +// diffed on its own. +func TestCasePairFindsTheRestatedCaseInARun(t *testing.T) { + base := map[string]string{"A": "a", "B": "b"} + changed := map[string]string{"A": "a", "B": "CHANGED"} + log := func(m string) ast.MicroflowStatement { + return &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Value: m, Kind: ast.LiteralString}} + } + ins := []ast.MicroflowStatement{log("new1"), log("new2"), caseStmt("Root/Src", changed, "A", "B")} + del := []ast.MicroflowStatement{log("old1"), log("old2"), caseStmt("Root/Src", base, "A", "B")} + if i, j, ok := casePair(ins, del); !ok || i != 2 || j != 2 { + t.Fatalf("casePair = (%d, %d, %v), want (2, 2, true)", i, j, ok) + } + // Control: a case on another subject is not the stored one restated. + ins[2] = caseStmt("Root/Other", changed, "A", "B") + if _, _, ok := casePair(ins, del); ok { + t.Error("a case on another subject was paired") + } +} From d91abfdbedb005fb96cf5bcae05299e0e6a8958e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 16:52:15 +0000 Subject: [PATCH 07/16] feat(test): run a .test.mdl test in the live app without building it (--allow-call) run --local --test-endpoint --allow-call switches on an exec route in the test endpoint. test --attach compiles each test into versioned steps (interp.go) and the app runs them with the Core API, every expression evaluated by Core.evaluateExpression; nothing is injected or built. A suite with a statement the executor does not support takes the usual inject-and-build route, naming the statement; verdicts are the same strings either way. Because the runtime evaluator returns empty for an unknown name, an undeclared variable is refused by the compiler and by the executor, and an unknown enumeration value by the executor (ERROR, never a silent compare against empty). A model newer than the running build is refused, with `mxcli run wait` as the fix under --watch. The route takes POST, needs the token and a loopback caller, and answers 403 without --allow-call. Verified on an 11.12.1 app: seven tests give identical verdicts and messages on both routes, 0.26 s against 3.2 s. Design from the review of mendixlabs/mxcli#1789 (refs mendixlabs/mxcli#1788). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .claude/skills/mendix/run-local/SKILL.md | 2 +- .../skills/mendix/test-microflows/SKILL.md | 4 +- CHANGELOG.md | 1 + cmd/mxcli/cmd_run.go | 8 +- cmd/mxcli/testrunner/client.go | 41 ++ cmd/mxcli/testrunner/endpoint.go | 6 + cmd/mxcli/testrunner/endpoint_exec.go | 503 ++++++++++++++++++ cmd/mxcli/testrunner/handshake.go | 4 + cmd/mxcli/testrunner/host.go | 14 +- cmd/mxcli/testrunner/interp.go | 447 ++++++++++++++++ cmd/mxcli/testrunner/interp_test.go | 180 +++++++ cmd/mxcli/testrunner/runner.go | 5 + cmd/mxcli/testrunner/runner_attach.go | 35 ++ cmd/mxcli/testrunner/runner_endpoint.go | 45 +- docs-site/src/tools/running-tests.md | 43 ++ .../proposal-mdl-test-framework.md | 16 +- .../cmd_microflows_builder_actions.go | 8 + 17 files changed, 1340 insertions(+), 22 deletions(-) create mode 100644 cmd/mxcli/testrunner/endpoint_exec.go create mode 100644 cmd/mxcli/testrunner/interp.go create mode 100644 cmd/mxcli/testrunner/interp_test.go diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 7c13f7c68..56925f3ec 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -352,7 +352,7 @@ run's process session), and `run status` reports them. Do **not** | `--screenshot-path` / `--screenshot-url` | `.mxcli/run-local.png` / app root | Screenshot output / page (URL or `/path`) | | `--screenshot-user` / `--screenshot-password` | — | Log in once, reuse session (pages behind login) | | `--runtime-log` | `.mxcli/runtime.log` | Runtime log file: JVM stdout/stderr **and** the application log (microflow `LOG` output + server stack traces, via an attached file log subscriber). `-` disables. | -| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See `test-microflows`. | +| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. Add `--allow-call` and `test --attach` runs a test **without injecting or building it** when the endpoint can execute all its statements (it then calls the microflows a test names in a system context, hence opt-in). See `test-microflows`. | | `--debug` | off | Enable the microflow debugger at boot + start a session, so `mxcli debug break/paused/…` works from another terminal (see `debug-microflows`). No breakpoints = no behaviour change; disabled on shutdown. | | `--debug-pass` | `mxdebug` | Debugger password when `--debug` is set | | `--metrics` | off | Register a Prometheus meter registry at boot; the runtime serves metrics at `http://127.0.0.1:/prometheus` | diff --git a/.claude/skills/mendix/test-microflows/SKILL.md b/.claude/skills/mendix/test-microflows/SKILL.md index 0f7f8169f..20c10d3bd 100644 --- a/.claude/skills/mendix/test-microflows/SKILL.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -23,7 +23,7 @@ description: "Write and run MDL-based microflow tests with `mxcli test` — anno | 647-659 | Interpreting Failures | | 660-681 | CI Integration | | 682-688 | Related Skills | -| 689-691 | Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`) | +| 689-693 | Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`) | # Test Microflows Skill @@ -689,3 +689,5 @@ The JUnit XML works with GitHub Actions, Jenkins, Azure DevOps, GitLab CI, etc. ## Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`) local test runs go through a **token-guarded HTTP endpoint** registered by a generated Java custom request handler, instead of compiling the suite into the project's after-startup microflow. Boot registers the endpoint and then **chains the project's own after-startup microflow**, so tests see the app as it really boots (`--skip-app-startup` opts out) — without that, a suite depending on startup state passed under `--attach` and failed under `--local`. One microflow per test, resolved by name at request time from `Core.getMicroflowNames()` and invoked with `Core.microflowCall(...).execute(...)` — so a throwing test fails only itself (not the boot), results are returned rather than scraped from the runtime log, and each test has its own variable scope. Owning the `IContext` is also what finally makes **`@cleanup rollback`** (the annotation's documented default, previously parsed and ignored) real: the handler wraps the call in `startTransaction()`/`rollbackTransaction()`, so a test's writes do not survive it — verified against Postgres, with `@cleanup none` as the in-run control. A rollback that fails is reported per test and summarised, never silent; an unknown strategy is a parse error. The handler **survives `reload_model`** (after-startup does not re-run, the JVM is unchanged), which is what makes `--watch` possible: ~30s first run, then ~2s from an edit — to a test *or* to the microflow under test — to a verdict. `--attach` skips even that boot by running against an app already up under `run --local --test-endpoint`, driving that process's serve + admin APIs over loopback; it uses **that app's database**, only ever adds/removes its own test microflows, and refuses a change needing a restart. Security: the handler is **not registered at all** without `MXCLI_TEST_TOKEN` in the runtime env (so a project that kept the `MxTest` module through a failed cleanup is inert in production), the token is constant-time compared, non-loopback callers are refused, `/list` is clamped to the test namespace, and only `MxTest.Test_*` may be invoked. The token reaches the runtime via its environment and is never written into the project. A `.test.mdl` file is **checkable**: each block is a microflow body, so `mxcli check` (and the LSP, hence VS Code) renders the blocks as the microflows they become, on the file's own lines — before #1103 the top-level grammar was applied instead, and since `RETRIEVE` is a non-reserved keyword the leftover `FROM …` started an OQL query, so the reader was told their retrieve needed a SELECT; 9 of this repo's 10 test files reported errors that way, one of them 392. `make check-mdl` now sweeps them, with `.fail.test.mdl` for a file whose annotations are deliberately unusable. Two things a failed run must not do, both reported as #1104: **a rejected build is reported with MxBuild's own errors** — `BuildResult.ErrorSummary()` was in hand and discarded by `fmt.Errorf("build failed: %s", build.Message)` on the `--attach` and rebuild paths, and that sentence is identical for every failing build, so it could not tell "your test does not compile" from "an unrelated document is broken"; and **cleanup removes every generated `MxTest.Test_*` the project holds**, not just this suite's. The names are positional and every file reuses them, so keying cleanup on the suite left a flow behind whenever a later run had fewer tests than an earlier one — and one leftover that does not build fails every later run of every test file. What cleanup could not remove is named, with the `DROP` that removes it. Docker keeps the after-startup runner (`--legacy-runner` selects it locally). Packages: `cmd/mxcli/testrunner/` (`endpoint.go`, `client.go`, `watch.go`, `host.go`, `handshake.go`, `check_source.go`, `cleanup_leftovers.go`). See `docs/15-testing/SPIKE_test_endpoint_request_handler.md` + +**No build at all (`--allow-call`).** Start the dev loop with `run --local --watch --test-endpoint --allow-call`, and `mxcli test … --attach` sends each test to the app as compiled steps instead of injecting and building it, so a test edit costs one HTTP round trip. Still `.test.mdl`. Supported: `declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` (`where`, `first`), and `@expect` (with `count`), `@throws`, `@setup`, `@cleanup`. Anything else (if, loop, association retrieve, sort/limit, `on error` other than rollback, nanoflow/Java calls) makes the run inject and build as before, and the output names the statement. Verdicts are the same either way. An undeclared `$variable` or a non-existent enumeration value is an ERROR, never a silent empty. A model newer than the running build is refused: `mxcli run wait -p app.mpr` under `--watch`. Details: `docs-site/src/tools/running-tests.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c76af4c7..70df7e1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`mxcli test --attach` runs a test without building it, against an app started with `--allow-call`** (mendixlabs/mxcli#1788, mendixlabs/mxcli#1789) — `run --local --test-endpoint --allow-call` switches on an executor in the test endpoint. A `.test.mdl` test whose statements it supports (`declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` with `where`/`first`; `@expect` with `count`, `@throws`, `@setup`, `@cleanup`) is compiled by mxcli into steps and run in the app with the Mendix runtime API, every expression evaluated by `Core.evaluateExpression`. Nothing is injected or built, so a test edit costs one HTTP round trip: on a small app the suite took 0.26 s against 3.2 s for inject-and-build, with identical verdicts. A suite with any other statement takes the usual route, and the output names the statement. Because the runtime evaluator returns empty for an unknown name, an undeclared `$variable` and a non-existent enumeration value are refused (ERROR) before evaluating, and a model newer than the running build is refused with `mxcli run wait` as the fix under `--watch`. The route takes POST, needs the run's token and a loopback caller, and answers 403 without `--allow-call`, since it calls the microflows a test names in a system context. `.test.mdl` stays the only test language. Design discussed with the authors of #1789; measurements in `mdl-examples/spikes/evaluate-expression.mdl`. - **`flows_for()` and `CATALOG.FLOWS`: a flow's sequence flows in lint rules, and each activity's place on the canvas** (ako/mxcli#1299, mendixlabs/mxcli#1308) — `activities_for()` listed the objects but not which leads to which, so a rule could not tell which log belongs to which custom error handler, which activity an annotation explains, or which branch of a split is `true`. Each sequence and annotation flow is now a row with its origin and destination activity, `is_error_handler`, the branch's `case_value` and the sides it connects; activities gain `x`, `y`, `width` and `height`. Full catalog; the schema moves to version 44, so a cached catalog is rebuilt once. - **Agent prompts in full-text search** (ako/mxcli#2075) — `CATALOG.STRINGS` indexed only an agent's documentation, so `search` could not find its system or user prompt, or the description the model reads for each tool and knowledge base: the most important text in an agentic app. Each is now a row with `ObjectType` `AGENT` and a `StringContext` naming it (`system_prompt`, `user_prompt`, `agent_description`, `tool_description`, `tool_display_title`, `tool_display_description`, `knowledge_base_description`). A published MCP service's tool descriptions and titles are rows of the service too (`mcp_tool_description`, `mcp_tool_title`). Catalog schema 43. - **Agent details in the catalog: `AGENT_TOOLS`, `AGENT_KNOWLEDGE_BASES`, `AGENT_MCP_SERVICES`, `PUBLISHED_MCP_SERVICES`, and each agent's settings** (ako/mxcli#2075) — `CATALOG.AGENTS` and its siblings held a document's name only, so which agents can run a tool without the user's approval, which use a model, or which have streaming on took one `describe agent` per agent. `AGENTS` gains the usage type, model, selected model, tool choice, generation settings, streaming switches (`NULL` when not stored), context entity, variables and tool counts; `AI_MODELS` and `KNOWLEDGE_BASES` the provider and key constant; `CONSUMED_MCP_SERVICES` the protocol version, version and timeout. An agent's tools and knowledge base tools are rows of their own, and a published MCP service is a catalog object whose tool and authentication microflows are `publish` edges, so they are no longer listed in `GRAPH_DEAD_ASSETS`. Catalog schema 42. diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index bf8120d67..9022d74b7 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -202,6 +202,11 @@ Examples: webClientTimeout, _ := cmd.Flags().GetDuration("web-client-timeout") testEndpoint, _ := cmd.Flags().GetBool("test-endpoint") + allowCall, _ := cmd.Flags().GetBool("allow-call") + if allowCall && !testEndpoint { + fmt.Fprintln(os.Stderr, "Error: --allow-call switches on a route of the test endpoint; it needs --test-endpoint") + os.Exit(1) + } ensureDB, _ := cmd.Flags().GetBool("ensure-db") appPort, _ := cmd.Flags().GetInt("app-port") adminPort, _ := cmd.Flags().GetInt("admin-port") @@ -317,7 +322,7 @@ Examples: fmt.Fprintf(os.Stderr, "Warning: could not git-ignore the test endpoint's source: %v\n", err) } var err error - hosted, err = testrunner.InstallHostedEndpoint(projectPath, os.Stdout) + hosted, err = testrunner.InstallHostedEndpoint(projectPath, allowCall, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -418,6 +423,7 @@ func init() { runCmd.Flags().Bool("detach", false, "Run in the background and return once the app is serving (or failed to boot); manage it with 'mxcli run status|wait|stop|restart'. Output goes to /.mxcli/run.log") runCmd.Flags().Duration("web-client-timeout", 0, "Limit for one web client bundle build, e.g. 10m (default $MXCLI_WEB_CLIENT_TIMEOUT, else 5m); on timeout the tail of deployment/log/web-client-build.log is printed") runCmd.Flags().Bool("test-endpoint", false, "Host mxcli's token-guarded test endpoint so 'mxcli test --attach' can run tests against this app without booting its own runtime (removed on exit)") + runCmd.Flags().Bool("allow-call", false, "With --test-endpoint: let 'mxcli test --attach' run a test straight against this app, without injecting or building it, when every statement of the test is one the endpoint can execute. The endpoint then calls the microflows a test names and reads and writes data in a system context, so it is off unless asked for") runCmd.Flags().Bool("ensure-db", false, "Provision the local Postgres + app database if missing (fresh-session bootstrap)") runCmd.Flags().Bool("setup", false, "Prepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook") runCmd.Flags().Int("app-port", 0, "HTTP port for the app (default 8080)") diff --git a/cmd/mxcli/testrunner/client.go b/cmd/mxcli/testrunner/client.go index 9060ca9e2..84fdb360f 100644 --- a/cmd/mxcli/testrunner/client.go +++ b/cmd/mxcli/testrunner/client.go @@ -3,6 +3,7 @@ package testrunner import ( + "bytes" "encoding/json" "fmt" "io" @@ -100,6 +101,46 @@ func (c *endpointClient) get(route string, params url.Values, out any) error { return nil } +// exec sends one compiled test to the endpoint's exec route (interp.go) and +// returns its reply, in the run route's shape. A POST, so the test travels in +// the body: a query string has a length limit and lands in access logs. +func (c *endpointClient) exec(p *execProgram) (*runResponse, error) { + body, err := json.Marshal(p) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, c.baseURL+endpointExecRoute, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set(endpointTokenHeader, c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + var rr runResponse + if resp.StatusCode != http.StatusOK { + var e struct { + Error string `json:"error"` + } + _ = json.Unmarshal(data, &e) + if e.Error == "" { + e.Error = truncate(strings.TrimSpace(string(data)), 200) + } + return nil, fmt.Errorf("the test endpoint refused the test (HTTP %d): %s", resp.StatusCode, e.Error) + } + if err := json.Unmarshal(data, &rr); err != nil { + return nil, fmt.Errorf("decoding response: %w: %s", err, truncate(string(data), 200)) + } + return &rr, nil +} + // ping reports whether the endpoint is up and accepting our token. func (c *endpointClient) ping() error { var lr listResponse diff --git a/cmd/mxcli/testrunner/endpoint.go b/cmd/mxcli/testrunner/endpoint.go index de63a6cca..bec837e82 100644 --- a/cmd/mxcli/testrunner/endpoint.go +++ b/cmd/mxcli/testrunner/endpoint.go @@ -224,6 +224,11 @@ com.mendix.core.Core.addRequestHandler("` + endpointPath + `", new com.mendix.ex return; } + if ("` + endpointExecRoute + `".equals(path)) { + doExec(request, response, out); + return; + } + if (!"run".equals(path)) { response.setStatus(com.mendix.m2ee.api.IMxRuntimeResponse.NOT_FOUND); out.write("{\"error\":\"no such route\",\"path\":" + esc(path) + "}"); @@ -308,6 +313,7 @@ com.mendix.core.Core.addRequestHandler("` + endpointPath + `", new com.mendix.ex out.write(b.toString()); out.flush(); } +` + endpointExecJava + ` }); log.info("MxTest: test endpoint registered at /` + endpointPath + `"); diff --git a/cmd/mxcli/testrunner/endpoint_exec.go b/cmd/mxcli/testrunner/endpoint_exec.go new file mode 100644 index 000000000..be271d348 --- /dev/null +++ b/cmd/mxcli/testrunner/endpoint_exec.go @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +// endpointAllowExecEnv switches the endpoint's exec route on. mxcli sets it in +// the runtime's environment only for `run --local --test-endpoint --allow-call`, +// so the route is off in every other run, including every `mxcli test --local`. +// +// The route is gated rather than left out of the generated Java so the endpoint +// script stays one constant: a build of it is the same build whatever the run, +// which is what lets a reused deployment (and the reuse key) ignore the flag. +const endpointAllowExecEnv = "MXCLI_TEST_ALLOW_EXEC" + +// endpointExecRoute is the route the executor listens on. +const endpointExecRoute = "exec" + +// endpointExecJava is the executor: the methods of the request handler that run +// a compiled test (interp.go) inside the running app. +// +// What it widens, and why that is behind --allow-call: the run route only ever +// runs a microflow this runner generated. The executor calls the microflows a +// test names, creates, changes, commits and deletes objects and queries by +// XPath, all in a system context. That is what a test body does in its +// generated microflow too, but here it is reachable without a build, so it is +// off unless the developer asked for it, and like every route it still needs +// the token and a loopback caller. +// +// Every expression goes to Core.evaluateExpression, so it means what it means in +// a microflow. Before each evaluation the executor checks what that evaluator +// does not: a variable it does not know, and an enumeration value that does not +// exist, both of which it returns as empty instead of failing (measured, +// mdl-examples/spikes/evaluate-expression.mdl). +// +// Verdicts are the strings the generated microflows return, decided at the same +// points: an error in a call is a FAIL (or the outcome of an @throws), an error +// anywhere else escapes the test and is reported as an ERROR, an @expect is +// checked only after the whole body ran, and the first failing one is the +// verdict. +// +// It must never contain two dollar signs in a row: the script embeds it in an +// MDL dollar-quoted literal, which ends at the first one. +const endpointExecJava = ` + private final boolean execAllowed = "1".equals(System.getenv("` + endpointAllowExecEnv + `")); + + private void doExec(com.mendix.m2ee.api.IMxRuntimeRequest request, + com.mendix.m2ee.api.IMxRuntimeResponse response, + java.io.Writer out) throws Exception { + if (!execAllowed) { + response.setStatus(com.mendix.m2ee.api.IMxRuntimeResponse.FORBIDDEN); + out.write("{\"error\":\"the exec route is off: start the app with mxcli run --local --test-endpoint --allow-call\"}"); + out.flush(); + return; + } + Object servlet = com.mendix.m2ee.api.IMxRuntimeRequest.class.getMethod("getHttpServletRequest").invoke(request); + String method = String.valueOf(servlet.getClass().getMethod("getMethod").invoke(servlet)); + if (!"POST".equalsIgnoreCase(method)) { + response.setStatus(405); + out.write("{\"error\":\"the exec route takes POST\"}"); + out.flush(); + return; + } + java.util.Map prog; + try { + java.io.InputStream in = (java.io.InputStream) servlet.getClass().getMethod("getInputStream").invoke(servlet); + String body = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + prog = asMap(new JsonReader(body).read(), "the request"); + Object v = prog.get("v"); + if (!(v instanceof Long) || ((Long) v).longValue() != ` + execProtocolVersionJava + `) { + throw new IllegalArgumentException("unsupported step format version " + v + "; this endpoint reads ` + execProtocolVersionJava + `"); + } + } catch (Exception e) { + response.setStatus(com.mendix.m2ee.api.IMxRuntimeResponse.BAD_REQUEST); + out.write("{\"error\":" + esc("bad exec request: " + e.getMessage()) + "}"); + out.flush(); + return; + } + + String test = String.valueOf(prog.get("test")); + boolean rollback = Boolean.TRUE.equals(prog.get("rollback")); + long t0 = System.nanoTime(); + com.mendix.systemwideinterfaces.core.IContext ctx = com.mendix.core.Core.createSystemContext(); + String result = null, error = null, rollbackError = null; + boolean rolledBack = false; + if (rollback) ctx.startTransaction(); + try { + result = execProgram(ctx, prog); + } catch (Throwable t) { + error = errorMessage(t); + } finally { + if (rollback) { + try { + ctx.rollbackTransaction(); + rolledBack = true; + } catch (Throwable t) { + rollbackError = errorMessage(t); + } + } + } + long micros = (System.nanoTime() - t0) / 1000L; + + StringBuilder b = new StringBuilder("{"); + b.append("\"mf\":").append(esc(test)); + b.append(",\"ok\":").append(error == null); + b.append(",\"durationMicros\":").append(micros); + b.append(",\"result\":").append(result == null ? "null" : esc(result)); + if (error != null) b.append(",\"error\":").append(esc(error)); + b.append(",\"rollbackRequested\":").append(rollback); + b.append(",\"rolledBack\":").append(rolledBack); + if (rollbackError != null) b.append(",\"rollbackError\":").append(esc(rollbackError)); + b.append('}'); + out.write(b.toString()); + out.flush(); + } + + // execProgram runs one compiled test and returns its verdict. + private String execProgram(com.mendix.systemwideinterfaces.core.IContext ctx, + java.util.Map prog) throws Exception { + java.util.Map vars = new java.util.HashMap(); + for (Object s : asList(prog.get("setups"))) { + String flow = String.valueOf(s); + try { + com.mendix.core.Core.microflowCall(flow).execute(ctx); + } catch (Throwable t) { + return "` + verdictSetupPrefix + `" + flow; + } + } + java.util.Map throwsSpec = prog.get("throws") == null ? null : asMap(prog.get("throws"), "throws"); + for (Object o : asList(prog.get("steps"))) { + java.util.Map st = asMap(o, "a step"); + if (!"call".equals(st.get("op"))) { + // Outside a call, an error escapes the test, as it does from the + // generated microflow: an ERROR, never a verdict. + execStep(ctx, st, vars); + continue; + } + try { + execCall(ctx, st, vars); + } catch (Throwable t) { + String msg = errorMessage(t); + if (throwsSpec == null) return "` + verdictFailPrefix + `exception during execution: " + msg; + String want = str(throwsSpec.get("want")); + if (want.isEmpty() || anyMessageContains(t, want)) return "` + verdictPass + `"; + return "` + verdictFailPrefix + `expected an error containing '" + want + "', actual: " + msg; + } + } + if (throwsSpec != null) return "` + verdictFailPrefix + `expected an exception but none was thrown"; + for (Object o : asList(prog.get("expects"))) { + java.util.Map ex = asMap(o, "an expect"); + Object ok = eval(ctx, vars, str(ex.get("condition"))); + if (Boolean.TRUE.equals(ok)) continue; + String msg = "` + verdictFailPrefix + `expected " + str(ex.get("raw")); + String actual = str(ex.get("actual")); + if (!actual.isEmpty()) { + Object a = eval(ctx, vars, actual); + msg += ", actual: " + (a == null ? "" : String.valueOf(a)); + } + return msg; + } + return "` + verdictPass + `"; + } + + private void execStep(com.mendix.systemwideinterfaces.core.IContext ctx, + java.util.Map st, + java.util.Map vars) throws Exception { + String op = str(st.get("op")); + String var = str(st.get("var")); + if ("declare".equals(op)) { + if (Boolean.TRUE.equals(st.get("list"))) { + vars.put(var, new java.util.ArrayList()); + } else { + String expr = str(st.get("expr")); + vars.put(var, expr.isEmpty() ? null : eval(ctx, vars, expr)); + } + } else if ("set".equals(op)) { + if (!vars.containsKey(var)) throw new IllegalArgumentException("set of $" + var + ", which is not declared"); + vars.put(var, eval(ctx, vars, str(st.get("expr")))); + } else if ("create".equals(op)) { + com.mendix.systemwideinterfaces.core.IMendixObject obj = com.mendix.core.Core.instantiate(ctx, str(st.get("entity"))); + setMembers(ctx, obj, st, vars); + commitIf(ctx, obj, st); + vars.put(var, obj); + } else if ("change".equals(op)) { + com.mendix.systemwideinterfaces.core.IMendixObject obj = object(vars, var); + setMembers(ctx, obj, st, vars); + commitIf(ctx, obj, st); + } else if ("commit".equals(op)) { + commitIf(ctx, vars.get(var), st); + } else if ("delete".equals(op)) { + Object v = vars.get(var); + if (v instanceof java.util.List) { + for (Object e : (java.util.List) v) com.mendix.core.Core.delete(ctx, (com.mendix.systemwideinterfaces.core.IMendixObject) e); + } else { + com.mendix.core.Core.delete(ctx, object(vars, var)); + } + } else if ("retrieve".equals(op)) { + String xpath = bindXPath(ctx, vars, str(st.get("xpath"))); + java.util.List rows = + com.mendix.core.Core.createXPathQuery(xpath).execute(ctx); + if (Boolean.TRUE.equals(st.get("first"))) { + vars.put(var, rows.isEmpty() ? null : rows.get(0)); + } else { + vars.put(var, new java.util.ArrayList(rows)); + } + } else if ("count".equals(op)) { + Object list = vars.get(str(st.get("of"))); + vars.put(var, Long.valueOf(list instanceof java.util.List ? ((java.util.List) list).size() : 0)); + } else { + throw new IllegalArgumentException("unknown step " + op); + } + } + + private void execCall(com.mendix.systemwideinterfaces.core.IContext ctx, + java.util.Map st, + java.util.Map vars) throws Exception { + String flow = str(st.get("flow")); + if (!com.mendix.core.Core.getMicroflowNames().contains(flow)) { + throw new IllegalArgumentException("unknown microflow " + flow); + } + java.util.Map sig = com.mendix.core.Core.getInputParameters(flow); + java.util.Map args = new java.util.HashMap(); + for (Object o : asList(st.get("args"))) { + java.util.Map a = asMap(o, "an argument"); + String name = str(a.get("name")); + String expr = str(a.get("expr")); + if (sig == null || !sig.containsKey(name)) { + throw new IllegalArgumentException("microflow " + flow + " has no parameter " + name + + (sig == null ? "" : " (it has " + sig.keySet() + ")")); + } + String type = sig.get(name).getType().name(); + Object v = eval(ctx, vars, expr); + if (v instanceof Long && "Integer".equalsIgnoreCase(type)) v = Integer.valueOf(((Long) v).intValue()); + if (v != null && !(v instanceof String) && type.toLowerCase().startsWith("enum")) v = eval(ctx, vars, "getKey(" + expr + ")"); + args.put(name, v); + } + Object result = com.mendix.core.Core.microflowCall(flow).withParams(args).execute(ctx); + String var = str(st.get("var")); + if (!var.isEmpty()) vars.put(var, result); + } + + private void setMembers(com.mendix.systemwideinterfaces.core.IContext ctx, + com.mendix.systemwideinterfaces.core.IMendixObject obj, + java.util.Map st, + java.util.Map vars) throws Exception { + for (Object o : asList(st.get("members"))) { + java.util.Map m = asMap(o, "a member"); + String name = str(m.get("name")); + String expr = str(m.get("expr")); + Object v = eval(ctx, vars, expr); + if (name.contains(".")) { + // An association: the object, a list of them, or empty. + if (v instanceof com.mendix.systemwideinterfaces.core.IMendixObject) { + v = ((com.mendix.systemwideinterfaces.core.IMendixObject) v).getId(); + } else if (v instanceof java.util.List) { + java.util.List ids = new java.util.ArrayList(); + for (Object e : (java.util.List) v) ids.add(((com.mendix.systemwideinterfaces.core.IMendixObject) e).getId()); + v = ids; + } + obj.setValue(ctx, name, v); + continue; + } + com.mendix.systemwideinterfaces.core.meta.IMetaPrimitive meta = obj.getMetaObject().getMetaPrimitive(name); + if (meta == null) throw new IllegalArgumentException(obj.getType() + " has no attribute " + name); + String type = meta.getType().name(); + if (v instanceof Long && "Integer".equalsIgnoreCase(type)) v = Integer.valueOf(((Long) v).intValue()); + if (v != null && !(v instanceof String) && type.toLowerCase().startsWith("enum")) v = eval(ctx, vars, "getKey(" + expr + ")"); + obj.setValue(ctx, name, v); + } + } + + private void commitIf(com.mendix.systemwideinterfaces.core.IContext ctx, Object target, + java.util.Map st) throws Exception { + if (!Boolean.TRUE.equals(st.get("commit")) || target == null) return; + boolean events = Boolean.TRUE.equals(st.get("events")); + java.util.List objs = new java.util.ArrayList(); + if (target instanceof java.util.List) { + for (Object e : (java.util.List) target) objs.add((com.mendix.systemwideinterfaces.core.IMendixObject) e); + } else { + objs.add((com.mendix.systemwideinterfaces.core.IMendixObject) target); + } + if (events) com.mendix.core.Core.commit(ctx, objs); + else com.mendix.core.Core.commitWithoutEvents(ctx, objs); + } + + private com.mendix.systemwideinterfaces.core.IMendixObject object(java.util.Map vars, String var) { + Object v = vars.get(var); + if (!(v instanceof com.mendix.systemwideinterfaces.core.IMendixObject)) { + throw new IllegalArgumentException("$" + var + " is " + (v == null ? "empty" : "not an object")); + } + return (com.mendix.systemwideinterfaces.core.IMendixObject) v; + } + + // eval checks what Core.evaluateExpression does not, then evaluates. + private Object eval(com.mendix.systemwideinterfaces.core.IContext ctx, + java.util.Map vars, String expr) throws Exception { + for (String name : variablesIn(expr)) { + if (!vars.containsKey(name) && !"currentUser".equals(name) && !"currentSession".equals(name)) { + throw new IllegalArgumentException("unknown variable $" + name + " in " + expr); + } + } + for (String lit : enumLiteralsIn(expr)) { + if (com.mendix.core.Core.evaluateExpression(ctx, new java.util.HashMap(), lit) == null) { + throw new IllegalArgumentException("unknown enumeration value " + lit + " in " + expr); + } + } + return com.mendix.core.Core.evaluateExpression(ctx, vars, expr); + } + + // bindXPath replaces each $variable (or $variable/path) outside a string + // literal with the literal of its value, which is what a microflow's + // retrieve does with them. + private String bindXPath(com.mendix.systemwideinterfaces.core.IContext ctx, + java.util.Map vars, String xpath) throws Exception { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < xpath.length(); i++) { + char c = xpath.charAt(i); + if (c == '\'') { + int j = endOfLiteral(xpath, i); + b.append(xpath, i, j + 1); + i = j; + continue; + } + if (c != '$') { + b.append(c); + continue; + } + int j = i + 1; + while (j < xpath.length() && (Character.isLetterOrDigit(xpath.charAt(j)) || "_/.".indexOf(xpath.charAt(j)) >= 0)) j++; + Object v = eval(ctx, vars, xpath.substring(i, j)); + b.append(xpathLiteral(v)); + i = j - 1; + } + return b.toString(); + } + + private String xpathLiteral(Object v) { + if (v == null) return "empty"; + if (v instanceof com.mendix.systemwideinterfaces.core.IMendixObject) return String.valueOf(((com.mendix.systemwideinterfaces.core.IMendixObject) v).getId().toLong()); + if (v instanceof com.mendix.systemwideinterfaces.core.IMendixIdentifier) return String.valueOf(((com.mendix.systemwideinterfaces.core.IMendixIdentifier) v).toLong()); + if (v instanceof Boolean) return ((Boolean) v) ? "true()" : "false()"; + if (v instanceof Number) return v.toString(); + if (v instanceof java.util.Date) return String.valueOf(((java.util.Date) v).getTime()); + return "'" + String.valueOf(v).replace("'", "''") + "'"; + } + + private static int endOfLiteral(String s, int open) { + for (int i = open + 1; i < s.length(); i++) { + if (s.charAt(i) != '\'') continue; + if (i + 1 < s.length() && s.charAt(i + 1) == '\'') { i++; continue; } + return i; + } + return s.length() - 1; + } + + // variablesIn is expressionVariables in interp.go, in Java. + private static java.util.List variablesIn(String expr) { + java.util.List out = new java.util.ArrayList(); + for (int i = 0; i < expr.length(); i++) { + char c = expr.charAt(i); + if (c == '\'') { i = endOfLiteral(expr, i); continue; } + if (c != '$') continue; + int j = i + 1; + while (j < expr.length() && (Character.isLetterOrDigit(expr.charAt(j)) || expr.charAt(j) == '_')) j++; + if (j > i + 1) out.add(expr.substring(i + 1, j)); + i = j - 1; + } + return out; + } + + // enumLiteralsIn finds Module.Enumeration.Value names: three dotted parts, + // not inside a string, and not a member of a path ($x/Mod.Assoc/Mod.Entity) + // or a constant (@Mod.Const). + private static java.util.List enumLiteralsIn(String expr) { + java.util.List out = new java.util.ArrayList(); + for (int i = 0; i < expr.length(); i++) { + char c = expr.charAt(i); + if (c == '\'') { i = endOfLiteral(expr, i); continue; } + if (!Character.isLetter(c)) continue; + int j = i; + while (j < expr.length() && (Character.isLetterOrDigit(expr.charAt(j)) || "_.".indexOf(expr.charAt(j)) >= 0)) j++; + char before = i == 0 ? ' ' : expr.charAt(i - 1); + String tok = expr.substring(i, j); + if (before != '/' && before != '@' && before != '$' && before != '.' + && tok.split("\\.", -1).length == 3) { + out.add(tok); + } + i = j - 1; + } + return out; + } + + private static String str(Object o) { return o == null ? "" : String.valueOf(o); } + + @SuppressWarnings("unchecked") + private static java.util.Map asMap(Object o, String what) { + if (!(o instanceof java.util.Map)) throw new IllegalArgumentException(what + " is not an object"); + return (java.util.Map) o; + } + + private static java.util.List asList(Object o) { + return o instanceof java.util.List ? (java.util.List) o : java.util.Collections.emptyList(); + } + + private static String errorMessage(Throwable t) { + Throwable root = t; + while (root.getCause() != null && root.getCause() != root) root = root.getCause(); + String msg = root.getMessage(); + return (msg == null || msg.isEmpty()) ? root.getClass().getName() : msg; + } + + // anyMessageContains matches @throws against every message in the cause + // chain: which level carries the text a microflow's $latestError/Message + // shows depends on where the error was raised. + private static boolean anyMessageContains(Throwable t, String want) { + for (Throwable c = t; c != null; c = c.getCause() == c ? null : c.getCause()) { + if (c.getMessage() != null && c.getMessage().contains(want)) return true; + } + return false; + } + + // A JSON reader for the step format: objects, arrays, strings, integers, + // booleans and null. Integers come back as Long; nothing else is needed. + private static final class JsonReader { + private final String s; + private int i; + JsonReader(String s) { this.s = s; } + + Object read() { + Object v = value(); + ws(); + if (i != s.length()) throw new IllegalArgumentException("trailing text at " + i); + return v; + } + + private void ws() { while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++; } + + private Object value() { + ws(); + if (i >= s.length()) throw new IllegalArgumentException("unexpected end"); + char c = s.charAt(i); + if (c == '{') { + java.util.Map m = new java.util.LinkedHashMap(); + i++; ws(); + if (s.charAt(i) == '}') { i++; return m; } + while (true) { + ws(); + String k = string(); + ws(); + if (s.charAt(i++) != ':') throw new IllegalArgumentException("expected : at " + i); + m.put(k, value()); + ws(); + char d = s.charAt(i++); + if (d == '}') return m; + if (d != ',') throw new IllegalArgumentException("expected , or } at " + i); + } + } + if (c == '[') { + java.util.List l = new java.util.ArrayList(); + i++; ws(); + if (s.charAt(i) == ']') { i++; return l; } + while (true) { + l.add(value()); + ws(); + char d = s.charAt(i++); + if (d == ']') return l; + if (d != ',') throw new IllegalArgumentException("expected , or ] at " + i); + } + } + if (c == '"') return string(); + if (s.startsWith("true", i)) { i += 4; return Boolean.TRUE; } + if (s.startsWith("false", i)) { i += 5; return Boolean.FALSE; } + if (s.startsWith("null", i)) { i += 4; return null; } + int start = i; + if (c == '-') i++; + while (i < s.length() && Character.isDigit(s.charAt(i))) i++; + if (start == i) throw new IllegalArgumentException("unexpected character at " + i); + return Long.valueOf(s.substring(start, i)); + } + + private String string() { + if (s.charAt(i) != '"') throw new IllegalArgumentException("expected a string at " + i); + StringBuilder b = new StringBuilder(); + for (i++; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"') { i++; return b.toString(); } + if (c != '\\') { b.append(c); continue; } + char e = s.charAt(++i); + switch (e) { + case 'n': b.append('\n'); break; + case 'r': b.append('\r'); break; + case 't': b.append('\t'); break; + case 'b': b.append('\b'); break; + case 'f': b.append('\f'); break; + case 'u': b.append((char) Integer.parseInt(s.substring(i + 1, i + 5), 16)); i += 4; break; + default: b.append(e); + } + } + throw new IllegalArgumentException("unterminated string"); + } + } +` + +// execProtocolVersionJava is execProtocolVersion as Java source text. +const execProtocolVersionJava = "1" diff --git a/cmd/mxcli/testrunner/handshake.go b/cmd/mxcli/testrunner/handshake.go index 8682cd3c6..368fd0125 100644 --- a/cmd/mxcli/testrunner/handshake.go +++ b/cmd/mxcli/testrunner/handshake.go @@ -45,6 +45,10 @@ type Handshake struct { Token string `json:"token"` // Started is when the dev loop published this, for a clearer stale message. Started time.Time `json:"started"` + // AllowExec records that the app was started with --allow-call, so its + // endpoint runs compiled tests (interp.go) and an attach can skip injecting + // and building them. + AllowExec bool `json:"allowExec,omitempty"` } // HandshakePath is the handshake file's location for a project. diff --git a/cmd/mxcli/testrunner/host.go b/cmd/mxcli/testrunner/host.go index 8e5845ba4..1b21135fb 100644 --- a/cmd/mxcli/testrunner/host.go +++ b/cmd/mxcli/testrunner/host.go @@ -25,6 +25,8 @@ type HostedEndpoint struct { Token string // Env is the entry to add to the runtime process environment. Env []string + // AllowExec switches on the endpoint's exec route (--allow-call). + AllowExec bool projectPath string state projectState @@ -37,7 +39,11 @@ type HostedEndpoint struct { // // The project's own after-startup microflow is chained rather than displaced, so // the app still boots the way the developer expects. -func InstallHostedEndpoint(projectPath string, w io.Writer) (*HostedEndpoint, error) { +// +// allowExec switches on the exec route (--allow-call): the runtime is given +// endpointAllowExecEnv, and the handshake says so. Without it the route answers +// 403, whatever a caller sends. +func InstallHostedEndpoint(projectPath string, allowExec bool, w io.Writer) (*HostedEndpoint, error) { token, err := newEndpointToken() if err != nil { return nil, err @@ -63,11 +69,16 @@ func InstallHostedEndpoint(projectPath string, w io.Writer) (*HostedEndpoint, er h := &HostedEndpoint{ Token: token, Env: []string{endpointTokenEnv + "=" + token}, + AllowExec: allowExec, projectPath: projectPath, state: state, out: w, } + if allowExec { + h.Env = append(h.Env, endpointAllowExecEnv+"=1") + } + fmt.Fprintln(w, "Installing the mxcli test endpoint (for 'mxcli test --attach')...") if err := execMDLScript(projectPath, GenerateEndpointMDL(state.afterStartup), "mxtest-endpoint-*.mdl"); err != nil { h.Remove() @@ -98,6 +109,7 @@ func (h *HostedEndpoint) Publish(info docker.LocalAppInfo) error { ServePort: info.ServePort, AdminPass: info.AdminPass, Token: h.Token, + AllowExec: h.AllowExec, Started: nowFunc(), }) if err != nil { diff --git a/cmd/mxcli/testrunner/interp.go b/cmd/mxcli/testrunner/interp.go new file mode 100644 index 000000000..9baaf5ed0 --- /dev/null +++ b/cmd/mxcli/testrunner/interp.go @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "fmt" + "strings" + "time" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/mendixexpr" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// The interpreter route. +// +// A test normally becomes a generated microflow that has to be injected and +// built before it can run. When the app already hosts the endpoint with +// --allow-call, and every statement in a test is one the endpoint's executor +// supports, the test is compiled here instead into a list of steps and sent to +// the running app, which carries them out with the Mendix Core API and hands +// every expression to Core.evaluateExpression. Nothing is injected and nothing +// is built, so editing a test costs one HTTP round trip. +// +// .test.mdl stays the only test language: this is a second way to run the same +// test, never a second way to write one. The steps are an internal format +// between this compiler and the endpoint Java mxcli itself generates, so they +// are versioned and free to change. +// +// Everything that can be settled offline is settled here — which statements +// are supported, the XPath of a retrieve, the Mendix text of each expression, +// and that every variable is declared before it is read — so a mistake is +// reported before anything reaches the app. The executor re-checks variable +// names and enumeration values itself, because Core.evaluateExpression returns +// empty for an unknown one instead of failing (spike in +// mdl-examples/spikes/evaluate-expression.mdl), and an @expect comparing +// against that empty would report a pass it never earned. +// +// A test outside the supported set is not an error: it takes the generated +// microflow route, with the same verdicts. The verdict strings are the ones the +// generated microflows return (verdictPass, verdictFailPrefix, +// verdictSetupPrefix), so results are read by the same code either way. + +// execProtocolVersion is the version of the step format. The endpoint refuses a +// version it does not know, so an attach to an app started by another mxcli +// says so instead of misreading the steps. +const execProtocolVersion = 1 + +// execProgram is one test, compiled for the executor. +type execProgram struct { + V int `json:"v"` + Test string `json:"test"` + // Setups are the @setup microflows, called without arguments before the body. + Setups []string `json:"setups,omitempty"` + Steps []execStep `json:"steps"` + // Expects are evaluated in order after the body; the first failure is the verdict. + Expects []execExpect `json:"expects,omitempty"` + // Throws is set for an @throws test; Want is empty for a bare @throws. + Throws *execThrows `json:"throws,omitempty"` + // Rollback wraps the test in a transaction that is rolled back afterwards. + Rollback bool `json:"rollback"` +} + +type execThrows struct { + Want string `json:"want"` +} + +// execStep is one statement of a test body. Op decides which fields apply. +type execStep struct { + Op string `json:"op"` // declare, set, create, change, commit, delete, call, retrieve, count + // Var is the variable the step writes (declare, set, create, call output, + // retrieve, count) or the object it acts on (change, commit, delete). + Var string `json:"var,omitempty"` + Expr string `json:"expr,omitempty"` // declare, set + List bool `json:"list,omitempty"` // declare of a list type + Entity string `json:"entity,omitempty"` // create, retrieve + // Members are a create's or change's assignments, in the order written. + Members []execMember `json:"members,omitempty"` + // Commit is set on create/change ... commit, and on commit; Events says + // whether before/after-commit events run. + Commit bool `json:"commit,omitempty"` + Events bool `json:"events,omitempty"` + Flow string `json:"flow,omitempty"` // call + Args []execArg `json:"args,omitempty"` // call + XPath string `json:"xpath,omitempty"` + First bool `json:"first,omitempty"` + Of string `json:"of,omitempty"` // count: the list variable + Line int `json:"line,omitempty"` +} + +type execMember struct { + Name string `json:"name"` + Expr string `json:"expr"` +} + +type execArg struct { + Name string `json:"name"` + Expr string `json:"expr"` +} + +type execExpect struct { + Raw string `json:"raw"` + Condition string `json:"condition"` + Actual string `json:"actual,omitempty"` +} + +// compileForExec compiles a test for the executor. It returns the reason the +// test cannot take the interpreter route when it cannot; that is a routing +// decision, not a failure of the test. +func compileForExec(tc TestCase) (*execProgram, string) { + if len(tc.AssertionErrors) > 0 { + return nil, "the test has an assertion that cannot be evaluated" + } + body, err := parseTestBody(tc) + if err != nil { + return nil, err.Error() + } + + p := &execProgram{ + V: execProtocolVersion, + Test: tc.Name, + Setups: append([]string(nil), tc.Setups...), + Rollback: rollsBack(tc), + } + if tc.expectsThrow() { + p.Throws = &execThrows{Want: tc.Throws} + } + + declared := map[string]bool{} + use := func(expr string) string { + if name := firstUndeclared(expr, declared); name != "" { + return fmt.Sprintf("$%s is read before anything declares it", name) + } + return "" + } + for _, st := range body { + step, reason := compileStep(st, use) + if reason != "" { + return nil, reason + } + if step.Var != "" && step.Op != "change" && step.Op != "commit" && step.Op != "delete" { + declared[step.Var] = true + } + p.Steps = append(p.Steps, step) + } + + if p.Throws == nil { + for _, exp := range tc.Expects { + for _, agg := range exp.Aggregates { + if agg.Op != "COUNT" { + return nil, "the @expect aggregate " + agg.Op + " is not supported by the executor" + } + v, list := strings.TrimPrefix(agg.Var, "$"), strings.TrimPrefix(agg.List, "$") + if !declared[list] { + return nil, fmt.Sprintf("$%s is read before anything declares it", list) + } + if !declared[v] { + p.Steps = append(p.Steps, execStep{Op: "count", Var: v, Of: list}) + declared[v] = true + } + } + if r := use(exp.Condition); r != "" { + return nil, r + } + if r := use(exp.Actual); r != "" { + return nil, r + } + p.Expects = append(p.Expects, execExpect{Raw: exp.Raw, Condition: exp.Condition, Actual: exp.Actual}) + } + } + return p, "" +} + +// parseTestBody parses a test's statements exactly as the generators read them: +// inside the same wrapper and under the same language version. +func parseTestBody(tc TestCase) ([]ast.MicroflowStatement, error) { + src := bodyParsePrefix(tc.Version) + tc.MDL + bodyParseSuffix + prog, errs := visitor.Build(src) + if len(errs) > 0 { + return nil, fmt.Errorf("the test body does not parse: %v", errs[0]) + } + for _, st := range prog.Statements { + if mf, ok := st.(*ast.CreateMicroflowStmt); ok { + return mf.Body, nil + } + } + return nil, fmt.Errorf("the test body does not parse as a microflow body") +} + +// compileStep compiles one statement, or says why the executor cannot run it. +func compileStep(st ast.MicroflowStatement, use func(string) string) (execStep, string) { + expr := func(e ast.Expression) (string, string) { + s := mendixexpr.String(e) + return s, use(s) + } + members := func(changes []ast.ChangeItem) ([]execMember, string) { + var out []execMember + for _, ch := range changes { + if ch.Op != ast.MemberSet { + return nil, "adding to or removing from a reference set (+= / -=)" + } + s, r := expr(ch.Value) + if r != "" { + return nil, r + } + out = append(out, execMember{Name: ch.Attribute, Expr: s}) + } + return out, "" + } + + switch s := st.(type) { + case *ast.DeclareStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + step := execStep{Op: "declare", Var: s.Variable, List: s.Type.Kind == ast.TypeListOf} + if s.InitialValue != nil { + e, r := expr(s.InitialValue) + if r != "" { + return execStep{}, r + } + step.Expr = e + } + return step, "" + + case *ast.MfSetStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + target := strings.TrimPrefix(s.Target, "$") + if strings.Contains(target, "/") { + return execStep{}, "set on an attribute path (write change instead)" + } + if r := use("$" + target); r != "" { + return execStep{}, r + } + e, r := expr(s.Value) + if r != "" { + return execStep{}, r + } + return execStep{Op: "set", Var: target, Expr: e}, "" + + case *ast.CreateObjectStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + m, r := members(s.Changes) + if r != "" { + return execStep{}, r + } + commit, events := commitFlag(s.Commit) + return execStep{Op: "create", Var: s.Variable, Entity: s.EntityType.String(), Members: m, + Commit: commit, Events: events}, "" + + case *ast.ChangeObjectStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + if r := use("$" + s.Variable); r != "" { + return execStep{}, r + } + m, r := members(s.Changes) + if r != "" { + return execStep{}, r + } + commit, events := commitFlag(s.Commit) + return execStep{Op: "change", Var: s.Variable, Members: m, Commit: commit, Events: events}, "" + + case *ast.MfCommitStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + if r := use("$" + s.Variable); r != "" { + return execStep{}, r + } + return execStep{Op: "commit", Var: s.Variable, Commit: true, Events: !s.WithoutEvents}, "" + + case *ast.DeleteObjectStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + if r := use("$" + s.Variable); r != "" { + return execStep{}, r + } + return execStep{Op: "delete", Var: s.Variable}, "" + + case *ast.CallMicroflowStmt: + if s.Queue != nil { + return execStep{}, "a call in a task queue" + } + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + step := execStep{Op: "call", Var: s.OutputVariable, Flow: s.MicroflowName.String()} + for _, a := range s.Arguments { + e, r := expr(a.Value) + if r != "" { + return execStep{}, r + } + step.Args = append(step.Args, execArg{Name: a.Name, Expr: e}) + } + return step, "" + + case *ast.RetrieveStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + switch { + case s.StartVariable != "": + return execStep{}, "a retrieve over an association" + case len(s.SortColumns) > 0: + return execStep{}, "a retrieve with sort by" + case s.Limit != "" || s.Offset != "": + return execStep{}, "a retrieve with limit or offset" + } + entity := s.Source.String() + xpath := "//" + entity + if s.Where != nil { + c := executor.RetrieveXPathConstraint(s.Where, entity) + if r := use(c); r != "" { + return execStep{}, r + } + xpath += strings.Join(strings.Fields(c), " ") + } + return execStep{Op: "retrieve", Var: s.Variable, Entity: entity, XPath: xpath, First: s.First}, "" + } + return execStep{}, "the statement " + statementKind(st) +} + +// plainErrorHandling accepts the error handling the executor reproduces: none, +// or `on error rollback`, which is the same model. Anything else changes what +// the test means when a call fails, and only the microflow route runs it as +// written. +func plainErrorHandling(h *ast.ErrorHandlingClause) string { + if h == nil || h.Type == ast.ErrorHandlingRollback { + return "" + } + return "an ON ERROR clause other than rollback" +} + +// commitFlag maps a create/change commit setting to the executor's two flags. +func commitFlag(c ast.CommitFlag) (commit, events bool) { + switch c { + case ast.CommitYes: + return true, true + case ast.CommitYesWithoutEvents: + return true, false + } + return false, false +} + +// statementKind names a statement type for a routing message. +func statementKind(st ast.MicroflowStatement) string { + name := fmt.Sprintf("%T", st) + name = strings.TrimPrefix(name, "*ast.") + name = strings.TrimSuffix(name, "Stmt") + return name +} + +// expressionVariables lists the $variables an expression in Mendix's grammar +// reads, in order, skipping string literals. The executor runs the same scan +// before every evaluation (endpoint_exec.go), so the two cannot disagree about +// what a name is. +func expressionVariables(expr string) []string { + var out []string + for i := 0; i < len(expr); i++ { + c := expr[i] + if c == '\'' { + // A literal ends at the next apostrophe that is not doubled. + for i++; i < len(expr); i++ { + if expr[i] == '\'' { + if i+1 < len(expr) && expr[i+1] == '\'' { + i++ + continue + } + break + } + } + continue + } + if c != '$' { + continue + } + j := i + 1 + for j < len(expr) && isIdentByte(expr[j]) { + j++ + } + if j > i+1 { + out = append(out, expr[i+1:j]) + } + i = j - 1 + } + return out +} + +func isIdentByte(c byte) bool { + return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + +// executorBuiltins are the variables Mendix provides without a declaration. +var executorBuiltins = map[string]bool{"currentUser": true, "currentSession": true} + +// firstUndeclared returns the first variable expr reads that nothing has declared. +func firstUndeclared(expr string, declared map[string]bool) string { + for _, v := range expressionVariables(expr) { + if !declared[v] && !executorBuiltins[v] { + return v + } + } + return "" +} + +// compileSuiteForExec compiles every test for the executor. It returns the +// reason the suite has to take the microflow route when one test cannot be +// compiled: a run takes one route, so a suite is either injected and built or +// not touched at all. A test whose assertions are already an ERROR does not +// decide the route; it is reported without running either way. +func compileSuiteForExec(suite *TestSuite) (map[string]*execProgram, string) { + out := make(map[string]*execProgram, len(suite.Tests)) + for _, tc := range suite.Tests { + if len(tc.AssertionErrors) > 0 { + continue + } + p, reason := compileForExec(tc) + if reason != "" { + return nil, fmt.Sprintf("test %q: %s", tc.Name, reason) + } + out[tc.ID] = p + } + return out, "" +} + +// staleModelError refuses to run tests straight against an app whose model on +// disk is newer than the build it serves: the tests would exercise the old +// model, and a pass would be stale. A dev loop in --watch applies the change on +// its own, so waiting is enough; without --watch it needs a restart. +func staleModelError(projectPath string, source, built time.Time, watching bool) error { + if !source.After(built) { + return nil + } + fix := "mxcli run wait -p " + projectPath + " # the dev loop is watching; wait for it to apply the change" + if !watching { + fix = "mxcli run restart -p " + projectPath + " # the dev loop is not watching, so it never applies the change" + } + return fmt.Errorf("the model changed after the running app was built, so these tests would run against the old model\n %s", fix) +} diff --git a/cmd/mxcli/testrunner/interp_test.go b/cmd/mxcli/testrunner/interp_test.go new file mode 100644 index 000000000..5e595c207 --- /dev/null +++ b/cmd/mxcli/testrunner/interp_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "encoding/json" + "reflect" + "strconv" + "strings" + "testing" + "time" +) + +func compileOne(t *testing.T, src string) (*execProgram, string) { + t.Helper() + tests, err := parseMDLTests(src, "t.test.mdl") + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(tests) != 1 { + t.Fatalf("got %d tests, want 1", len(tests)) + } + return compileForExec(tests[0]) +} + +// The supported statements compile to steps in order, expressions in Mendix's +// grammar, and the @expect lines with their actual-value expressions. +func TestCompileForExec_SupportedBody(t *testing.T) { + p, reason := compileOne(t, `mdl 1; +/** + * @test Paying an invoice marks it paid + * @expect $inv/Status = Spike.Status.PAID + * @expect $n = 1 + */ +$cust = create Spike.Customer (Name = 'Acme'); +$inv = create Spike.Invoice (InvoiceNo = 'INV-1', Amount = 250.5, Spike.Invoice_Customer = $cust); +commit $inv; +declare $n Integer = 0; +call microflow Spike.Invoice_Pay(Invoice = $inv); +retrieve $paid from Spike.Invoice where Status = Spike.Status.PAID and InvoiceNo = $inv/InvoiceNo; +set $n = 1; +/ +`) + if reason != "" { + t.Fatalf("not interpretable: %s", reason) + } + var ops []string + for _, s := range p.Steps { + ops = append(ops, s.Op) + } + if want := []string{"create", "create", "commit", "declare", "call", "retrieve", "set"}; !reflect.DeepEqual(ops, want) { + t.Fatalf("ops = %v, want %v", ops, want) + } + inv := p.Steps[1] + if inv.Entity != "Spike.Invoice" || len(inv.Members) != 3 || inv.Members[2].Name != "Spike.Invoice_Customer" || + inv.Members[2].Expr != "$cust" || inv.Members[0].Expr != "'INV-1'" { + t.Errorf("create step = %+v", inv) + } + call := p.Steps[4] + if call.Flow != "Spike.Invoice_Pay" || len(call.Args) != 1 || call.Args[0].Name != "Invoice" || call.Args[0].Expr != "$inv" { + t.Errorf("call step = %+v", call) + } + ret := p.Steps[5] + if !strings.HasPrefix(ret.XPath, "//Spike.Invoice[") || !strings.Contains(ret.XPath, "$inv/InvoiceNo") { + t.Errorf("retrieve xpath = %q", ret.XPath) + } + if len(p.Expects) != 2 || p.Expects[0].Condition != "$inv/Status = Spike.Status.PAID" || p.Expects[0].Actual == "" { + t.Errorf("expects = %+v", p.Expects) + } + if !p.Rollback || p.Throws != nil || p.V != execProtocolVersion { + t.Errorf("program flags = %+v", p) + } + if _, err := json.Marshal(p); err != nil { + t.Fatal(err) + } +} + +// A test outside the supported set takes the microflow route, with a reason +// that names what kept it there. +func TestCompileForExec_RoutesTheRestToMicroflows(t *testing.T) { + for _, c := range []struct{ body, reason string }{ + {"if 1 = 1 then\n declare $x Integer = 1;\nend if;", "If"}, + {"loop $i in $list begin\nend loop;", "Loop"}, + {"$r = call microflow M.F() on error continue;", "ON ERROR"}, + {"retrieve $l from M.E sort by Name asc;", "sort"}, + {"retrieve $l from $o/M.Assoc;", "association"}, + {"set $x = 1;", "$x is read before anything declares it"}, + {"$r = call microflow M.F(A = $missing);", "$missing is read before"}, + } { + _, reason := compileOne(t, "mdl 1;\n/**\n * @test t\n * @expect true\n */\n"+c.body+"\n/\n") + if reason == "" || !strings.Contains(reason, c.reason) { + t.Errorf("%q: reason = %q, want it to mention %q", c.body, reason, c.reason) + } + } +} + +// An @expect that reads an undeclared variable is refused here: the runtime's +// evaluator would read it as empty and could report a pass. +func TestCompileForExec_ExpectReadingAnUndeclaredVariable(t *testing.T) { + _, reason := compileOne(t, "mdl 1;\n/**\n * @test t\n * @expect $reslt = 3\n */\n$result = call microflow M.F();\n/\n") + if !strings.Contains(reason, "$reslt") { + t.Errorf("reason = %q, want it to name $reslt", reason) + } +} + +func TestCompileForExec_ThrowsSetupAndCleanup(t *testing.T) { + p, reason := compileOne(t, `mdl 1; +/** + * @test Negative amount is refused + * @setup M.Seed + * @throws 'must be positive' + * @cleanup none + */ +$r = call microflow M.Apply(Amount = -5.0); +/ +`) + if reason != "" { + t.Fatalf("not interpretable: %s", reason) + } + if p.Throws == nil || p.Throws.Want != "must be positive" || len(p.Expects) != 0 { + t.Errorf("throws = %+v, expects = %+v", p.Throws, p.Expects) + } + if !reflect.DeepEqual(p.Setups, []string{"M.Seed"}) || p.Rollback { + t.Errorf("setups = %v, rollback = %v", p.Setups, p.Rollback) + } +} + +// A count(...) in an @expect becomes a count step after the body, as the +// generated microflow's aggregate activity does. +func TestCompileForExec_CountAggregate(t *testing.T) { + p, reason := compileOne(t, "mdl 1;\n/**\n * @test t\n * @expect count($l) = 2\n */\nretrieve $l from M.E;\n/\n") + if reason != "" { + t.Fatalf("not interpretable: %s", reason) + } + last := p.Steps[len(p.Steps)-1] + if last.Op != "count" || last.Of != "l" || last.Var == "" || !strings.Contains(p.Expects[0].Condition, "$"+last.Var) { + t.Errorf("count step = %+v, condition = %q", last, p.Expects[0].Condition) + } +} + +func TestExpressionVariables_SkipsStringLiterals(t *testing.T) { + got := expressionVariables("$a/Name + '$notavar it''s $x' + $b_2 + toString($c)") + if want := []string{"a", "b_2", "c"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +// A watching dev loop applies a model change itself, so the hint is to wait, +// not to restart; a loop that is not watching has to be restarted. +func TestStaleModelError(t *testing.T) { + built := time.Unix(1000, 0) + if err := staleModelError("/p/App.mpr", built, built, true); err != nil { + t.Errorf("an unchanged model was refused: %v", err) + } + err := staleModelError("/p/App.mpr", built.Add(5*time.Second), built, true) + if err == nil || !strings.Contains(err.Error(), "mxcli run wait -p /p/App.mpr") || strings.Contains(err.Error(), "restart") { + t.Errorf("watching: %v", err) + } + err = staleModelError("/p/App.mpr", built.Add(5*time.Second), built, false) + if err == nil || !strings.Contains(err.Error(), "mxcli run restart -p /p/App.mpr") { + t.Errorf("not watching: %v", err) + } +} + +// The executor's Java is embedded in an MDL dollar-quoted literal, which ends +// at the first doubled dollar sign; and it reads the step format version this +// compiler writes. +func TestEndpointExecJava(t *testing.T) { + if strings.Contains(endpointJava, "$$") { + t.Error("the endpoint Java contains $$, which ends the MDL literal it is embedded in") + } + if execProtocolVersionJava != strconv.Itoa(execProtocolVersion) { + t.Errorf("Java reads step format %s, the compiler writes %d", execProtocolVersionJava, execProtocolVersion) + } + for _, want := range []string{`"` + endpointExecRoute + `".equals(path)`, endpointAllowExecEnv, "evaluateExpression"} { + if !strings.Contains(GenerateEndpointMDL(""), want) { + t.Errorf("the endpoint script does not contain %q", want) + } + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index f6349f002..fda288ce7 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -39,6 +39,11 @@ type RunOptions struct { // SkipBuild skips the MxBuild step (reuse existing deployment). SkipBuild bool + // execPrograms, keyed on test ID, sends those tests to the endpoint's exec + // route instead of running a generated microflow (interp.go). Set only by + // an attach to an app started with --allow-call. + execPrograms map[string]*execProgram + // Local runs the app with mxcli's own local runtime (`run --local`) instead // of a Docker container, and drives the tests over the test endpoint. Local bool diff --git a/cmd/mxcli/testrunner/runner_attach.go b/cmd/mxcli/testrunner/runner_attach.go index 5c5334536..48cd85e85 100644 --- a/cmd/mxcli/testrunner/runner_attach.go +++ b/cmd/mxcli/testrunner/runner_attach.go @@ -5,6 +5,8 @@ package testrunner import ( "fmt" "io" + "os" + "path/filepath" "time" "github.com/mendixlabs/mxcli/cmd/mxcli/docker" @@ -174,6 +176,25 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. return result, err } + // An app started with --allow-call runs a compiled test itself, so a suite + // whose every test compiles is neither injected nor built (interp.go). + if app.hs.AllowExec && !opts.Watch { + if programs, reason := compileSuiteForExec(suite); reason != "" { + fmt.Fprintf(w, " injecting and building, because %s\n", reason) + } else { + if err := refuseStaleModel(opts.ProjectPath); err != nil { + return nil, err + } + direct := opts + direct.execPrograms = programs + result, err := runSuite(app.client, app.adminOptions(), suite, direct, w) + if err != nil { + return nil, err + } + return report(result, nil) + } + } + fmt.Fprintln(w, "Injecting test microflows...") if err := execMDLScript(opts.ProjectPath, GenerateTestFlows(suite), "mxtest-flows-*.mdl"); err != nil { return finish(nil, fmt.Errorf("injecting test microflows: %w", err)) @@ -208,3 +229,17 @@ func dropTestFlows(projectPath string, suite *TestSuite) []string { } return cmds } + +// refuseStaleModel is staleModelError for the project's own build: the model on +// disk against deployment/model/model.mdp, which every build rewrites. +func refuseStaleModel(projectPath string) error { + built, err := os.Stat(filepath.Join(filepath.Dir(projectPath), "deployment", "model", "model.mdp")) + if err != nil { + return fmt.Errorf("cannot tell which model the running app was built from (%v); restart the dev loop", err) + } + watching := false + if st, err := docker.ReadRunState(projectPath); err == nil && st != nil { + watching = st.Watch + } + return staleModelError(projectPath, docker.ProjectSourceMTime(projectPath), built.ModTime(), watching) +} diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index 410fc8530..0144e5361 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -124,16 +124,22 @@ func runSuite(client *endpointClient, admin docker.M2EEOptions, suite *TestSuite // is missing is reported as an error against that test rather than failing // the run. present := make(map[string]bool) - names, err := client.list() - if err != nil { - return nil, fmt.Errorf("listing test microflows: %w", err) - } - for _, n := range names { - present[n] = true + if opts.execPrograms == nil { + names, err := client.list() + if err != nil { + return nil, fmt.Errorf("listing test microflows: %w", err) + } + for _, n := range names { + present[n] = true + } } result := &SuiteResult{Name: suite.Name, Started: time.Now()} - fmt.Fprintf(w, "Running %d test(s) over the test endpoint...\n", len(suite.Tests)) + if opts.execPrograms != nil { + fmt.Fprintf(w, "Running %d test(s) in the running app (no injection, no build)...\n", len(suite.Tests)) + } else { + fmt.Fprintf(w, "Running %d test(s) over the test endpoint...\n", len(suite.Tests)) + } // leaked counts tests whose requested rollback did not happen. leaked := 0 @@ -144,17 +150,22 @@ func runSuite(client *endpointClient, admin docker.M2EEOptions, suite *TestSuite continue } - flow := testFlowName(tc) - if !present[flow] { - res := newResult(tc) - res.Status = StatusError - res.Message = fmt.Sprintf("microflow %s was not created — the test body may not have compiled", flow) - result.Tests = append(result.Tests, res) - continue - } - rollback := rollsBack(tc) - rr, err := client.run(flow, rollback) + var rr *runResponse + var err error + if prog := opts.execPrograms[tc.ID]; prog != nil { + rr, err = client.exec(prog) + } else { + flow := testFlowName(tc) + if !present[flow] { + res := newResult(tc) + res.Status = StatusError + res.Message = fmt.Sprintf("microflow %s was not created — the test body may not have compiled", flow) + result.Tests = append(result.Tests, res) + continue + } + rr, err = client.run(flow, rollback) + } if err != nil { // A transport failure is not a verdict. Report it against this test // and keep going; if the runtime died the rest will say so too. diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index 40632c349..ddb1f7305 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -149,6 +149,7 @@ Two more rules follow from the walk: | `--local` | ~30s | `_test` | no | | `--local --watch` | ~30s once, then ~2s | `_test` | no | | `--attach` | none | **the running app's** | no | +| `--attach` to an app started with `--allow-call` | none, and no build when the tests compile for the executor | **the running app's** | no | ```bash # No Docker daemon needed @@ -189,6 +190,48 @@ on a fresh serve process instead, and a fresh process's first build asks for a restart whatever changed — which an attach cannot do, the runtime being the other process's. Use `--local` on 11.14.0; 11.15.0 builds incrementally again. +### Running a test without building it (`--allow-call`) + +An attach normally injects the suite's test microflows and builds them into the +running app. When that app was started with `--allow-call`, a test whose +statements the endpoint can execute is not injected or built at all: mxcli +compiles it into a list of steps, the app runs them with the Mendix runtime API, +and every expression goes to Mendix's own evaluator (`Core.evaluateExpression`). +Editing a test then costs one HTTP round trip. + +```bash +mxcli run --local --watch --test-endpoint --allow-call -p app.mpr # terminal 1 +mxcli test tests/ -p app.mpr --attach # terminal 2 +``` + +You still write `.test.mdl`. The executor runs these statements: `declare`, +`set`, `create`, `change`, `commit`, `delete`, `call microflow`, and a database +`retrieve` with an optional `where` and `first`. It also handles `@expect` +(including `count(...)`), `@throws`, `@setup` and `@cleanup`. A test with +anything else (a decision, a loop, a retrieve over an association or with +`sort by`/`limit`, an `on error` clause other than `rollback`, a nanoflow or a +Java action call) makes the run take the usual route. mxcli prints which test and +which statement decided that, and the verdicts are the same on both routes. +The route is taken by a one-shot `--attach`; `test --attach --watch` still +injects and builds. + +Three things are checked before a test runs, because the runtime's evaluator +returns *empty* for a name it does not know instead of failing: + +- every `$variable` an expression reads must be declared earlier in the test + (checked by mxcli, then again by the app); +- every enumeration value an expression names must exist, so + `Module.Enum.TYPO` is an ERROR rather than a comparison against empty; +- the model on disk must not be newer than the build the app is serving. If it + is, the run is refused: with `--watch`, `mxcli run wait -p app.mpr` lets the dev + loop apply the change; without it, `mxcli run restart -p app.mpr`. + +`--allow-call` widens what the endpoint can do. Without it, the endpoint only +runs microflows the runner generated. With it, the endpoint calls the microflows +a test names and reads and writes data in a system context. It still needs the +run's token and a loopback caller, accepts only POST, and is off unless you pass +the flag. + ## How tests execute **`--local` — the test endpoint.** One microflow is generated per test, plus a diff --git a/docs/11-proposals/proposal-mdl-test-framework.md b/docs/11-proposals/proposal-mdl-test-framework.md index ffafba8f0..f4e8012ad 100644 --- a/docs/11-proposals/proposal-mdl-test-framework.md +++ b/docs/11-proposals/proposal-mdl-test-framework.md @@ -285,7 +285,21 @@ mxcli test tests/ -p app.mpr --run "String concatenation" --run "Boolean logic" --- -## Phase 2: HTTP-Based Execution (Future) +## Phase 2: HTTP-Based Execution + +> **What shipped (2026-11).** Phase 2 arrived in two parts, not as written below. +> First, a persistent endpoint that runs **generated** microflows: each test is +> injected as `MxTest.Test_*`, built once, and invoked over HTTP with a rollback +> (`mxcli test --local [--watch]`, `--attach`). Second, the interpreter this +> section describes, as an opt-in route of the same endpoint +> (`run --local --test-endpoint --allow-call`): mxcli compiles a `.test.mdl` test +> into steps, and the app runs them with the Core API, every expression evaluated +> by `Core.evaluateExpression` instead of an expression engine of its own. That +> needs no build when only a test changes. `.test.mdl` stays the only test +> language. The wire format is internal and versioned, not the user-facing JSON +> below, and a test the executor cannot run takes the generated-microflow route +> with the same verdicts. See `docs-site/src/tools/running-tests.md` and +> `cmd/mxcli/testrunner/interp.go`. Phase 2 replaces the after-startup pattern with a persistent HTTP endpoint in the runtime, enabling test execution without restarts. diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 0c49789ca..422c4ccc0 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -3334,3 +3334,11 @@ func qualifyNamesInSource(source string, names map[string]string) string { } return out.String() } + +// RetrieveXPathConstraint is the XPath constraint a `retrieve … where` stores, +// for a caller outside the executor that runs the retrieve itself: the test +// runner's interpreter route sends it to the running app instead of writing a +// Retrieve activity, and must send the same constraint the activity would hold. +func RetrieveXPathConstraint(where ast.Expression, entity string) string { + return retrieveXPathConstraint(where, entity) +} From a0e6dc24d764f6856a66b6148be49aa4316e9fcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 16:35:59 +0000 Subject: [PATCH 08/16] docs(spikes): measure Core.evaluateExpression as a test executor's expression engine Probes a microflow expression evaluator in the running app: types it returns, uncommitted objects and association paths, errors, the silent nulls (unknown variable, unknown enum value, $-prefixed map key), and the cost per call. Measured on 11.12.1; findings in the file header. Input for the interpreter route discussed on mendixlabs/mxcli#1789. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- mdl-examples/spikes/evaluate-expression.mdl | 218 ++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 mdl-examples/spikes/evaluate-expression.mdl diff --git a/mdl-examples/spikes/evaluate-expression.mdl b/mdl-examples/spikes/evaluate-expression.mdl new file mode 100644 index 000000000..52f7ec157 --- /dev/null +++ b/mdl-examples/spikes/evaluate-expression.mdl @@ -0,0 +1,218 @@ +mdl 1; +-- ============================================================================ +-- SPIKE: Core.evaluateExpression as the expression engine of a test executor +-- ============================================================================ +-- Question: can a test executor inside the running app hand Mendix microflow +-- expressions (an @expect, a computed value) to the runtime instead of +-- implementing its own evaluator? See the review of mendixlabs/mxcli#1789 and +-- Phase 2 of docs/11-proposals/proposal-mdl-test-framework.md. +-- +-- Run (no Postgres needed): +-- mxcli new EvSpike --version 11.12.1 --output-dir ev --theme none --layout none --skip-build +-- cd ev && ./mxcli exec -p EvSpike.mpr +-- ./mxcli run --local --detach --db-type hsqldb -p EvSpike.mpr +-- grep -h 'EVAL|' .mxcli/runtime.log +-- +-- Measured on Mendix 11.12.1 (Java 21), two boots, identical results: +-- +-- * It works, in a system context, inside an open transaction, on objects that +-- were never committed: $inv/Amount, an association hop to an uncommitted +-- object, and a comparison between them all read the in-memory state. +-- * Variables: map keys WITHOUT the '$' (a key "$x" is not found). +-- * Return types: Integer and Long both come back as java.lang.Long (also an +-- Integer attribute and an Integer variable); Decimal as BigDecimal (scale +-- kept: 250.50); DateTime as java.util.Date; empty as null; an association +-- member as a MendixIdentifier; an association hop ending in the entity as +-- the object. An enumeration value comes back as an INTERNAL runtime class +-- (MetaEnumeration$MetaEnumValue) -- report it with toString()/getKey(), +-- never by inspecting the object. +-- * Association paths need the entity step, as in microflows: +-- $inv/Spike.Invoice_Customer/Spike.Customer/Name works; +-- $inv/Spike.Invoice_Customer/Name fails ("Entity does not exist: Name"). +-- * SILENT empties, the most important finding: an unknown variable ($nope), +-- an unknown enumeration value (Spike.Status.NOPE) and an attribute read +-- through an empty object ($emptyObj/Amount) all return null, not an error. +-- A typo would make an @expect compare against empty. The offline check in +-- mxcli (variables, enumeration values, members) is therefore REQUIRED +-- before anything is sent, not a nicety. +-- * Errors that do throw: a syntax error (MendixRuntimeException, "could not +-- be parsed ... line 1 character 3"); division by zero, an unknown member, +-- and a type mismatch ($inv/Status = 'PAID', $inv/Status = 1) as +-- MicroflowException whose root cause carries the readable message. +-- * Lenient where microflows are lenient: $inv/Amount + 'x' is the string +-- "250.5x". +-- * $currentUser is empty and [%CurrentUser%] throws in a system context: a +-- test that needs a user needs a user session, not the system context. +-- * Speed: 2000 evaluations of a two-part comparison took 128 ms, and of an +-- association-path comparison 132 ms -- about 65 us per @expect. +-- ============================================================================ + +create module Spike; + +create enumeration Spike.Status ( + OPEN 'Open', + PAID 'Paid' +); + +create persistent entity Spike.Customer ( + Name: String(100) +); + +create persistent entity Spike.Invoice ( + InvoiceNo: String(50), + Amount: Decimal, + Qty: Integer, + BigNumber: Long, + Status: Enumeration(Spike.Status), + IssuedOn: DateTime +); + +create association Spike.Invoice_Customer from Spike.Invoice to Spike.Customer; + +create java action Spike.EvalProbe() returns String +as $$ +com.mendix.systemwideinterfaces.core.IContext ctx = com.mendix.core.Core.createSystemContext(); +ctx.startTransaction(); +try { + com.mendix.systemwideinterfaces.core.IMendixObject cust = com.mendix.core.Core.instantiate(ctx, "Spike.Customer"); + cust.setValue(ctx, "Name", "Acme"); + com.mendix.systemwideinterfaces.core.IMendixObject inv = com.mendix.core.Core.instantiate(ctx, "Spike.Invoice"); + inv.setValue(ctx, "InvoiceNo", "INV-1"); + inv.setValue(ctx, "Amount", new java.math.BigDecimal("250.50")); + inv.setValue(ctx, "Qty", 3); + inv.setValue(ctx, "BigNumber", 10000000000L); + inv.setValue(ctx, "Status", "PAID"); + inv.setValue(ctx, "IssuedOn", new java.util.Date(1767225600000L)); + inv.setValue(ctx, "Spike.Invoice_Customer", cust.getId()); + com.mendix.systemwideinterfaces.core.IMendixObject inv2 = com.mendix.core.Core.instantiate(ctx, "Spike.Invoice"); + inv2.setValue(ctx, "InvoiceNo", "INV-2"); + com.mendix.core.Core.commit(ctx, inv2); + + java.util.Map v = new java.util.HashMap(); + v.put("inv", inv); v.put("cust", cust); v.put("inv2", inv2); + v.put("n", 5L); v.put("i", 7); v.put("d", new java.math.BigDecimal("2.5")); v.put("s", "abc"); + v.put("b", Boolean.TRUE); v.put("emptyObj", null); v.put("$dollar", "with-dollar-key"); + + LOG.info("EVAL|meta|java.version=" + System.getProperty("java.version")); + // literals + probe(ctx, v, "lit", "1 + 2"); + probe(ctx, v, "lit", "1.5 * 2"); + probe(ctx, v, "lit", "10 div 4"); + probe(ctx, v, "lit", "'a' + 'b'"); + probe(ctx, v, "lit", "true and false"); + probe(ctx, v, "lit", "empty"); + probe(ctx, v, "lit", "dateTime(2026, 1, 15)"); + probe(ctx, v, "lit", "[%CurrentDateTime%]"); + probe(ctx, v, "lit", "Spike.Status.PAID"); + probe(ctx, v, "lit", "9223372036854775807"); + // variables + probe(ctx, v, "var", "$n + 1"); + probe(ctx, v, "var", "$i + 1"); + probe(ctx, v, "var", "$d * 2"); + probe(ctx, v, "var", "$s + '!'"); + probe(ctx, v, "var", "$b"); + probe(ctx, v, "var", "$dollar"); + probe(ctx, v, "var", "$emptyObj = empty"); + // paths on an uncommitted object + probe(ctx, v, "path", "$inv/InvoiceNo"); + probe(ctx, v, "path", "$inv/Amount"); + probe(ctx, v, "path", "$inv/Qty"); + probe(ctx, v, "path", "$inv/BigNumber"); + probe(ctx, v, "path", "$inv/Status"); + probe(ctx, v, "path", "$inv/IssuedOn"); + probe(ctx, v, "path", "$inv/Spike.Invoice_Customer"); + probe(ctx, v, "path", "$inv/Spike.Invoice_Customer/Name"); + probe(ctx, v, "path", "$cust/Name"); + probe(ctx, v, "path", "$inv2/InvoiceNo"); + // comparisons (what @expect would send) + probe(ctx, v, "cmp", "$inv/Status = Spike.Status.PAID"); + probe(ctx, v, "cmp", "$inv/Status = Spike.Status.OPEN"); + probe(ctx, v, "cmp", "$inv/Amount = 250.50"); + probe(ctx, v, "cmp", "$inv/Amount > 100"); + probe(ctx, v, "cmp", "$inv/Spike.Invoice_Customer = $cust"); + probe(ctx, v, "cmp", "$inv/Qty = 3"); + // functions + probe(ctx, v, "fn", "toString($inv/Status)"); + probe(ctx, v, "fn", "getCaption($inv/Status)"); + probe(ctx, v, "fn", "getKey($inv/Status)"); + probe(ctx, v, "fn", "formatDecimal($inv/Amount, '#.00')"); + probe(ctx, v, "fn", "if $inv/Amount > 100 then 'big' else 'small'"); + probe(ctx, v, "fn", "length($inv/InvoiceNo)"); + probe(ctx, v, "fn", "contains($inv/InvoiceNo, 'INV')"); + probe(ctx, v, "fn", "$inv/Amount * 0.9"); + probe(ctx, v, "fn", "formatDateTime($inv/IssuedOn, 'yyyy-MM-dd')"); + probe(ctx, v, "fn", "$currentUser"); + probe(ctx, v, "fn", "[%CurrentUser%]"); + // round 2: association paths with the entity step, committed vs uncommitted target + com.mendix.systemwideinterfaces.core.IMendixObject cust2 = com.mendix.core.Core.instantiate(ctx, "Spike.Customer"); + cust2.setValue(ctx, "Name", "Globex"); + com.mendix.core.Core.commit(ctx, cust2); + inv2.setValue(ctx, "Spike.Invoice_Customer", cust2.getId()); + probe(ctx, v, "assoc", "$inv/Spike.Invoice_Customer/Spike.Customer/Name"); + probe(ctx, v, "assoc", "$inv2/Spike.Invoice_Customer/Spike.Customer/Name"); + probe(ctx, v, "assoc", "$inv/Spike.Invoice_Customer/Spike.Customer = $cust"); + probe(ctx, v, "assoc", "$inv/Spike.Invoice_Customer/Spike.Customer"); + probe(ctx, v, "assoc", "$inv/Spike.Invoice_Customer/Spike.Customer/Name = 'Acme'"); + probe(ctx, v, "assoc", "$emptyObj/Spike.Invoice_Customer/Spike.Customer/Name"); + // enum: what the executor should send to report an actual value + probe(ctx, v, "enum", "toString($inv/Status) = 'PAID'"); + probe(ctx, v, "enum", "$inv/Status = empty"); + // types of the variables matter? an Integer passed as Integer vs Long + probe(ctx, v, "type", "$i = 7"); + probe(ctx, v, "type", "$n = 5"); + probe(ctx, v, "type", "$d = 2.5"); + // speed: 2000 evaluations of a typical @expect + long t0 = System.nanoTime(); + for (int k = 0; k < 2000; k++) com.mendix.core.Core.evaluateExpression(ctx, v, "$inv/Amount > 100 and $inv/Status = Spike.Status.PAID"); + LOG.info("EVAL|speed|2000 evals of a two-part comparison|" + ((System.nanoTime() - t0) / 1000000) + " ms total"); + long t1 = System.nanoTime(); + for (int k = 0; k < 2000; k++) com.mendix.core.Core.evaluateExpression(ctx, v, "$inv/Spike.Invoice_Customer/Spike.Customer/Name = 'Acme'"); + LOG.info("EVAL|speed|2000 evals of an association path|" + ((System.nanoTime() - t1) / 1000000) + " ms total"); + // errors + probe(ctx, v, "err", "1 +"); + probe(ctx, v, "err", "$nope"); + probe(ctx, v, "err", "1 div 0"); + probe(ctx, v, "err", "$emptyObj/Amount"); + probe(ctx, v, "err", "$inv/NoSuchAttr"); + probe(ctx, v, "err", "Spike.Status.NOPE"); + probe(ctx, v, "err", "$inv/Status = 1"); + probe(ctx, v, "err", "$inv/Status = 'PAID'"); + probe(ctx, v, "err", "$inv/Amount + 'x'"); +} finally { + ctx.rollbackTransaction(); +} +return "done"; +$$ +extra code $$ +private static final com.mendix.logging.ILogNode LOG = com.mendix.core.Core.getLogger("EvSpike"); + +private static String oneLine(String s) { + return s == null ? "null" : s.replace('\n', ' ').replace('\r', ' '); +} + +private static void probe(com.mendix.systemwideinterfaces.core.IContext ctx, java.util.Map vars, String label, String expr) { + try { + Object r = com.mendix.core.Core.evaluateExpression(ctx, vars, expr); + String cls = r == null ? "null" : r.getClass().getName(); + String val = String.valueOf(r); + if (r instanceof com.mendix.systemwideinterfaces.core.IMendixObject) { + com.mendix.systemwideinterfaces.core.IMendixObject o = (com.mendix.systemwideinterfaces.core.IMendixObject) r; + val = o.getType() + "#" + o.getId().toLong() + " new=" + o.isNew(); + } + LOG.info("EVAL|" + label + "|" + expr + "|OK|" + cls + "|" + oneLine(val)); + } catch (Throwable t) { + Throwable root = t; + while (root.getCause() != null && root.getCause() != root) root = root.getCause(); + LOG.info("EVAL|" + label + "|" + expr + "|ERR|" + t.getClass().getName() + ": " + oneLine(t.getMessage()) + + " || root " + root.getClass().getName() + ": " + oneLine(root.getMessage())); + } +} +$$; + +create microflow Spike.AfterStartup () returns Boolean +begin + $Report = call java action Spike.EvalProbe(); + return true; +end; + +alter settings runtime ( AfterStartupMicroflow: 'Spike.AfterStartup' ); From 811b7687236b573a5927ecd53d69e2d5a906a91a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 19:16:00 +0000 Subject: [PATCH 09/16] feat(test): route each test on its own, and run aggregates and association retrieves in the app From the field test on ako/mxcli#2101 (16 tests, Mendix 11.16): one unsupported statement sent the whole run through inject-and-build, and the commonest ones were list aggregates and retrieves over associations. - An attach to an --allow-call app now injects and builds only the tests the executor cannot run; the rest of the run still goes to the app. - The executor runs count/sum/average/minimum/maximum over an attribute as statements, and a retrieve over an association: the owner side of a reference through the evaluator (so an uncommitted object is found), otherwise the database list, as a microflow does. - A refusal names the MDL statement ("the statement if", "a list operation (find)") instead of the AST type. - run warns on --allow-call with --hub: the tunnel connects from loopback, so the token is the only guard. Checked differentially on 11.12.1 (mdl-examples/spikes/test-interpreter): the first comparison found Mendix's average over an empty list is empty, not 0; the executor now agrees, and both routes give identical verdicts and messages, a mixed run included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .../skills/mendix/test-microflows/SKILL.md | 2 +- CHANGELOG.md | 2 +- cmd/mxcli/cmd_run.go | 7 + cmd/mxcli/testrunner/endpoint_exec.go | 58 ++++++++ cmd/mxcli/testrunner/interp.go | 111 +++++++++++--- cmd/mxcli/testrunner/interp_test.go | 76 +++++++++- cmd/mxcli/testrunner/runner_attach.go | 29 ++-- cmd/mxcli/testrunner/runner_endpoint.go | 20 ++- docs-site/src/tools/running-tests.md | 23 +-- mdl-examples/spikes/test-interpreter/app.mdl | 32 ++++ .../test-interpreter/differential.test.mdl | 138 ++++++++++++++++++ 11 files changed, 453 insertions(+), 45 deletions(-) create mode 100644 mdl-examples/spikes/test-interpreter/app.mdl create mode 100644 mdl-examples/spikes/test-interpreter/differential.test.mdl diff --git a/.claude/skills/mendix/test-microflows/SKILL.md b/.claude/skills/mendix/test-microflows/SKILL.md index 20c10d3bd..36ae31be6 100644 --- a/.claude/skills/mendix/test-microflows/SKILL.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -690,4 +690,4 @@ The JUnit XML works with GitHub Actions, Jenkins, Azure DevOps, GitLab CI, etc. local test runs go through a **token-guarded HTTP endpoint** registered by a generated Java custom request handler, instead of compiling the suite into the project's after-startup microflow. Boot registers the endpoint and then **chains the project's own after-startup microflow**, so tests see the app as it really boots (`--skip-app-startup` opts out) — without that, a suite depending on startup state passed under `--attach` and failed under `--local`. One microflow per test, resolved by name at request time from `Core.getMicroflowNames()` and invoked with `Core.microflowCall(...).execute(...)` — so a throwing test fails only itself (not the boot), results are returned rather than scraped from the runtime log, and each test has its own variable scope. Owning the `IContext` is also what finally makes **`@cleanup rollback`** (the annotation's documented default, previously parsed and ignored) real: the handler wraps the call in `startTransaction()`/`rollbackTransaction()`, so a test's writes do not survive it — verified against Postgres, with `@cleanup none` as the in-run control. A rollback that fails is reported per test and summarised, never silent; an unknown strategy is a parse error. The handler **survives `reload_model`** (after-startup does not re-run, the JVM is unchanged), which is what makes `--watch` possible: ~30s first run, then ~2s from an edit — to a test *or* to the microflow under test — to a verdict. `--attach` skips even that boot by running against an app already up under `run --local --test-endpoint`, driving that process's serve + admin APIs over loopback; it uses **that app's database**, only ever adds/removes its own test microflows, and refuses a change needing a restart. Security: the handler is **not registered at all** without `MXCLI_TEST_TOKEN` in the runtime env (so a project that kept the `MxTest` module through a failed cleanup is inert in production), the token is constant-time compared, non-loopback callers are refused, `/list` is clamped to the test namespace, and only `MxTest.Test_*` may be invoked. The token reaches the runtime via its environment and is never written into the project. A `.test.mdl` file is **checkable**: each block is a microflow body, so `mxcli check` (and the LSP, hence VS Code) renders the blocks as the microflows they become, on the file's own lines — before #1103 the top-level grammar was applied instead, and since `RETRIEVE` is a non-reserved keyword the leftover `FROM …` started an OQL query, so the reader was told their retrieve needed a SELECT; 9 of this repo's 10 test files reported errors that way, one of them 392. `make check-mdl` now sweeps them, with `.fail.test.mdl` for a file whose annotations are deliberately unusable. Two things a failed run must not do, both reported as #1104: **a rejected build is reported with MxBuild's own errors** — `BuildResult.ErrorSummary()` was in hand and discarded by `fmt.Errorf("build failed: %s", build.Message)` on the `--attach` and rebuild paths, and that sentence is identical for every failing build, so it could not tell "your test does not compile" from "an unrelated document is broken"; and **cleanup removes every generated `MxTest.Test_*` the project holds**, not just this suite's. The names are positional and every file reuses them, so keying cleanup on the suite left a flow behind whenever a later run had fewer tests than an earlier one — and one leftover that does not build fails every later run of every test file. What cleanup could not remove is named, with the `DROP` that removes it. Docker keeps the after-startup runner (`--legacy-runner` selects it locally). Packages: `cmd/mxcli/testrunner/` (`endpoint.go`, `client.go`, `watch.go`, `host.go`, `handshake.go`, `check_source.go`, `cleanup_leftovers.go`). See `docs/15-testing/SPIKE_test_endpoint_request_handler.md` -**No build at all (`--allow-call`).** Start the dev loop with `run --local --watch --test-endpoint --allow-call`, and `mxcli test … --attach` sends each test to the app as compiled steps instead of injecting and building it, so a test edit costs one HTTP round trip. Still `.test.mdl`. Supported: `declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` (`where`, `first`), and `@expect` (with `count`), `@throws`, `@setup`, `@cleanup`. Anything else (if, loop, association retrieve, sort/limit, `on error` other than rollback, nanoflow/Java calls) makes the run inject and build as before, and the output names the statement. Verdicts are the same either way. An undeclared `$variable` or a non-existent enumeration value is an ERROR, never a silent empty. A model newer than the running build is refused: `mxcli run wait -p app.mpr` under `--watch`. Details: `docs-site/src/tools/running-tests.md`. +**No build at all (`--allow-call`).** Start the dev loop with `run --local --watch --test-endpoint --allow-call`, and `mxcli test … --attach` sends each test to the app as compiled steps instead of injecting and building it, so a test edit costs one HTTP round trip. Still `.test.mdl`. Supported: `declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` (`where`, `first`), `retrieve` over an association, the aggregates `count`/`sum`/`average`/`minimum`/`maximum` over an attribute, and `@expect` (with `count`), `@throws`, `@setup`, `@cleanup`, `@verify`. A test with anything else (if, loop, list operations, sort/limit, `on error` other than rollback, nanoflow/Java calls) is injected and built as before; only that test, and the output names it and the statement. Verdicts are the same either way. An undeclared `$variable` or a non-existent enumeration value is an ERROR, never a silent empty. A model newer than the running build is refused: `mxcli run wait -p app.mpr` under `--watch`. Details: `docs-site/src/tools/running-tests.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a37e0dd3..4e4168c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **`mxcli test --attach` runs a test without building it, against an app started with `--allow-call`** (mendixlabs/mxcli#1788, mendixlabs/mxcli#1789) — `run --local --test-endpoint --allow-call` switches on an executor in the test endpoint. A `.test.mdl` test whose statements it supports (`declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` with `where`/`first`; `@expect` with `count`, `@throws`, `@setup`, `@cleanup`) is compiled by mxcli into steps and run in the app with the Mendix runtime API, every expression evaluated by `Core.evaluateExpression`. Nothing is injected or built, so a test edit costs one HTTP round trip: on a small app the suite took 0.26 s against 3.2 s for inject-and-build, with identical verdicts. A suite with any other statement takes the usual route, and the output names the statement. Because the runtime evaluator returns empty for an unknown name, an undeclared `$variable` and a non-existent enumeration value are refused (ERROR) before evaluating, and a model newer than the running build is refused with `mxcli run wait` as the fix under `--watch`. The route takes POST, needs the run's token and a loopback caller, and answers 403 without `--allow-call`, since it calls the microflows a test names in a system context. `.test.mdl` stays the only test language. Design discussed with the authors of #1789; measurements in `mdl-examples/spikes/evaluate-expression.mdl`. +- **`mxcli test --attach` runs a test without building it, against an app started with `--allow-call`** (mendixlabs/mxcli#1788, mendixlabs/mxcli#1789) — `run --local --test-endpoint --allow-call` switches on an executor in the test endpoint. A `.test.mdl` test whose statements it supports (`declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` with `where`/`first`; `@expect` with `count`, `@throws`, `@setup`, `@cleanup`) is compiled by mxcli into steps and run in the app with the Mendix runtime API, every expression evaluated by `Core.evaluateExpression`. Nothing is injected or built, so a test edit costs one HTTP round trip: on a small app the suite took 0.26 s against 3.2 s for inject-and-build, with identical verdicts. Retrieves over an association and the list aggregates `count`/`sum`/`average`/`minimum`/`maximum` over an attribute run there too. A test with any other statement is injected and built as before. Only that test is, while the rest of the run still goes straight to the app, and the output names the test and the MDL statement (`the statement if`, `a list operation (find)`). Because the runtime evaluator returns empty for an unknown name, an undeclared `$variable` and a non-existent enumeration value are refused (ERROR) before evaluating, and a model newer than the running build is refused with `mxcli run wait` as the fix under `--watch`. The route takes POST, needs the run's token and a loopback caller, and answers 403 without `--allow-call`, since it calls the microflows a test names in a system context. `.test.mdl` stays the only test language. Design discussed with the authors of #1789; measurements in `mdl-examples/spikes/evaluate-expression.mdl`. - **`flows_for()` and `CATALOG.FLOWS`: a flow's sequence flows in lint rules, and each activity's place on the canvas** (ako/mxcli#1299, mendixlabs/mxcli#1308) — `activities_for()` listed the objects but not which leads to which, so a rule could not tell which log belongs to which custom error handler, which activity an annotation explains, or which branch of a split is `true`. Each sequence and annotation flow is now a row with its origin and destination activity, `is_error_handler`, the branch's `case_value` and the sides it connects; activities gain `x`, `y`, `width` and `height`. Full catalog; the schema moves to version 44, so a cached catalog is rebuilt once. - **Agent prompts in full-text search** (ako/mxcli#2075) — `CATALOG.STRINGS` indexed only an agent's documentation, so `search` could not find its system or user prompt, or the description the model reads for each tool and knowledge base: the most important text in an agentic app. Each is now a row with `ObjectType` `AGENT` and a `StringContext` naming it (`system_prompt`, `user_prompt`, `agent_description`, `tool_description`, `tool_display_title`, `tool_display_description`, `knowledge_base_description`). A published MCP service's tool descriptions and titles are rows of the service too (`mcp_tool_description`, `mcp_tool_title`). Catalog schema 43. - **Agent details in the catalog: `AGENT_TOOLS`, `AGENT_KNOWLEDGE_BASES`, `AGENT_MCP_SERVICES`, `PUBLISHED_MCP_SERVICES`, and each agent's settings** (ako/mxcli#2075) — `CATALOG.AGENTS` and its siblings held a document's name only, so which agents can run a tool without the user's approval, which use a model, or which have streaming on took one `describe agent` per agent. `AGENTS` gains the usage type, model, selected model, tool choice, generation settings, streaming switches (`NULL` when not stored), context entity, variables and tool counts; `AI_MODELS` and `KNOWLEDGE_BASES` the provider and key constant; `CONSUMED_MCP_SERVICES` the protocol version, version and timeout. An agent's tools and knowledge base tools are rows of their own, and a published MCP service is a catalog object whose tool and authentication microflows are `publish` edges, so they are no longer listed in `GRAPH_DEAD_ASSETS`. Catalog schema 42. diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index 9022d74b7..5d258f025 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -207,6 +207,13 @@ Examples: fmt.Fprintln(os.Stderr, "Error: --allow-call switches on a route of the test endpoint; it needs --test-endpoint") os.Exit(1) } + if allowCall && hub != "" { + // The tunnel client reaches the app from loopback, so the endpoint's + // loopback check does not keep hub traffic out of the exec route. + fmt.Fprintln(os.Stderr, "Warning: --allow-call with --hub: the hub tunnel reaches the app from loopback, so the test endpoint's\n"+ + " loopback check does not keep hub traffic out. The run's token is the only guard on the route that\n"+ + " calls microflows in a system context; drop --allow-call when the hub URL is shared.") + } ensureDB, _ := cmd.Flags().GetBool("ensure-db") appPort, _ := cmd.Flags().GetInt("app-port") adminPort, _ := cmd.Flags().GetInt("admin-port") diff --git a/cmd/mxcli/testrunner/endpoint_exec.go b/cmd/mxcli/testrunner/endpoint_exec.go index be271d348..1ef4ee700 100644 --- a/cmd/mxcli/testrunner/endpoint_exec.go +++ b/cmd/mxcli/testrunner/endpoint_exec.go @@ -192,6 +192,10 @@ const endpointExecJava = ` } else { com.mendix.core.Core.delete(ctx, object(vars, var)); } + } else if ("retrieve".equals(op) && !str(st.get("assoc")).isEmpty()) { + vars.put(var, retrieveByAssociation(ctx, vars, str(st.get("from")), str(st.get("assoc")))); + } else if ("aggregate".equals(op)) { + vars.put(var, aggregate(ctx, vars.get(str(st.get("of"))), str(st.get("agg")), str(st.get("attr")))); } else if ("retrieve".equals(op)) { String xpath = bindXPath(ctx, vars, str(st.get("xpath"))); java.util.List rows = @@ -209,6 +213,60 @@ const endpointExecJava = ` } } + // retrieveByAssociation follows an association as a microflow's retrieve + // does. From the owner of a reference it is the one object, read through the + // evaluator so an object not yet committed is found too; otherwise (the other + // side, or a reference set) it is the list the database holds. + private Object retrieveByAssociation(com.mendix.systemwideinterfaces.core.IContext ctx, + java.util.Map vars, + String from, String assoc) throws Exception { + com.mendix.systemwideinterfaces.core.IMendixObject start = object(vars, from); + com.mendix.systemwideinterfaces.core.meta.IMetaAssociation owned = null; + for (com.mendix.systemwideinterfaces.core.meta.IMetaAssociation a : start.getMetaObject().getMetaAssociationsParent()) { + if (a.getName().equals(assoc)) owned = a; + } + if (owned != null && owned.getType() == com.mendix.systemwideinterfaces.core.meta.IMetaAssociation.AssociationType.REFERENCE) { + return eval(ctx, vars, "\u0024" + from + "/" + assoc + "/" + owned.getChild().getName()); + } + return new java.util.ArrayList( + com.mendix.core.Core.retrieveByPath(ctx, start, assoc)); + } + + // aggregate is a microflow's Aggregate list activity over an attribute. + private Object aggregate(com.mendix.systemwideinterfaces.core.IContext ctx, Object list, + String agg, String attr) throws Exception { + java.util.List rows = list instanceof java.util.List ? (java.util.List) list : java.util.Collections.emptyList(); + if ("count".equals(agg)) return Long.valueOf(rows.size()); + java.util.List values = new java.util.ArrayList(); + for (Object o : rows) { + Object v = ((com.mendix.systemwideinterfaces.core.IMendixObject) o).getValue(ctx, attr); + if (v instanceof Integer) v = Long.valueOf(((Integer) v).longValue()); + if (v != null) values.add(v); + } + if ("sum".equals(agg) || "average".equals(agg)) { + boolean decimal = false; + java.math.BigDecimal sum = java.math.BigDecimal.ZERO; + for (Object v : values) { + if (v instanceof java.math.BigDecimal) decimal = true; + sum = sum.add(v instanceof java.math.BigDecimal ? (java.math.BigDecimal) v : java.math.BigDecimal.valueOf(((Number) v).longValue())); + } + if ("average".equals(agg)) { + // Empty, not zero: what Mendix's own Aggregate list activity + // returns for an empty list (measured against the generated route). + if (values.isEmpty()) return null; + return sum.divide(java.math.BigDecimal.valueOf(values.size()), java.math.MathContext.DECIMAL128); + } + return decimal ? (Object) sum : (Object) Long.valueOf(sum.longValueExact()); + } + Object best = null; + for (Object v : values) { + @SuppressWarnings("unchecked") + int c = best == null ? 0 : ((Comparable) v).compareTo(best); + if (best == null || ("minimum".equals(agg) ? c < 0 : c > 0)) best = v; + } + return best; + } + private void execCall(com.mendix.systemwideinterfaces.core.IContext ctx, java.util.Map st, java.util.Map vars) throws Exception { diff --git a/cmd/mxcli/testrunner/interp.go b/cmd/mxcli/testrunner/interp.go index 9baaf5ed0..cef36bfc6 100644 --- a/cmd/mxcli/testrunner/interp.go +++ b/cmd/mxcli/testrunner/interp.go @@ -68,7 +68,7 @@ type execThrows struct { // execStep is one statement of a test body. Op decides which fields apply. type execStep struct { - Op string `json:"op"` // declare, set, create, change, commit, delete, call, retrieve, count + Op string `json:"op"` // declare, set, create, change, commit, delete, call, retrieve, count, aggregate // Var is the variable the step writes (declare, set, create, call output, // retrieve, count) or the object it acts on (change, commit, delete). Var string `json:"var,omitempty"` @@ -85,7 +85,11 @@ type execStep struct { Args []execArg `json:"args,omitempty"` // call XPath string `json:"xpath,omitempty"` First bool `json:"first,omitempty"` - Of string `json:"of,omitempty"` // count: the list variable + Of string `json:"of,omitempty"` // count, aggregate: the list variable + Agg string `json:"agg,omitempty"` // aggregate: count, sum, average, minimum, maximum + Attr string `json:"attr,omitempty"` // aggregate: the attribute summed or compared + Assoc string `json:"assoc,omitempty"` // retrieve over an association: the association + From string `json:"from,omitempty"` // retrieve over an association: the start object Line int `json:"line,omitempty"` } @@ -307,8 +311,13 @@ func compileStep(st ast.MicroflowStatement, use func(string) string) (execStep, return execStep{}, r } switch { + case s.StartVariable != "" && (s.Where != nil || len(s.SortColumns) > 0 || s.First || s.Limit != "" || s.Offset != ""): + return execStep{}, "a retrieve over an association with where, sort by, first, limit or offset" case s.StartVariable != "": - return execStep{}, "a retrieve over an association" + if r := use("$" + s.StartVariable); r != "" { + return execStep{}, r + } + return execStep{Op: "retrieve", Var: s.Variable, Assoc: s.Source.String(), From: s.StartVariable}, "" case len(s.SortColumns) > 0: return execStep{}, "a retrieve with sort by" case s.Limit != "" || s.Offset != "": @@ -324,10 +333,33 @@ func compileStep(st ast.MicroflowStatement, use func(string) string) (execStep, xpath += strings.Join(strings.Fields(c), " ") } return execStep{Op: "retrieve", Var: s.Variable, Entity: entity, XPath: xpath, First: s.First}, "" + + case *ast.AggregateListStmt: + if r := plainErrorHandling(s.ErrorHandling); r != "" { + return execStep{}, r + } + agg, ok := execAggregates[s.Operation] + if !ok || s.IsExpression { + return execStep{}, "the statement " + statementKind(st) + } + if r := use("$" + s.InputVariable); r != "" { + return execStep{}, r + } + return execStep{Op: "aggregate", Var: s.OutputVariable, Of: s.InputVariable, Agg: agg, Attr: s.Attribute}, "" } return execStep{}, "the statement " + statementKind(st) } +// execAggregates are the list aggregates the executor computes. The expression +// form (`sum $l by expression`), reduce, all and any are not among them. +var execAggregates = map[ast.AggregateListOperationType]string{ + ast.AggregateCount: "count", + ast.AggregateSum: "sum", + ast.AggregateAverage: "average", + ast.AggregateMinimum: "minimum", + ast.AggregateMaximum: "maximum", +} + // plainErrorHandling accepts the error handling the executor reproduces: none, // or `on error rollback`, which is the same model. Anything else changes what // the test means when a call fails, and only the microflow route runs it as @@ -350,14 +382,45 @@ func commitFlag(c ast.CommitFlag) (commit, events bool) { return false, false } -// statementKind names a statement type for a routing message. +// statementKind names a statement for a routing message the way the test's +// author wrote it: "the statement count / sum … by" tells them what to rewrite, +// "the statement AggregateList" does not. func statementKind(st ast.MicroflowStatement) string { - name := fmt.Sprintf("%T", st) - name = strings.TrimPrefix(name, "*ast.") - name = strings.TrimSuffix(name, "Stmt") + switch s := st.(type) { + case *ast.AggregateListStmt: + switch { + case s.Operation == ast.AggregateReduce: + return "reduce" + case s.Operation == ast.AggregateAll || s.Operation == ast.AggregateAny: + return "all / any" + case s.IsExpression: + return "an aggregate by expression (" + strings.ToLower(s.Operation.String()) + " … by an expression)" + } + case *ast.ListOperationStmt: + return "a list operation (" + strings.ToLower(s.Operation.String()) + ")" + } + name := strings.TrimSuffix(strings.TrimPrefix(fmt.Sprintf("%T", st), "*ast."), "Stmt") + if mdl, ok := mdlStatementNames[name]; ok { + return mdl + } return name } +// mdlStatementNames spells statement types the way MDL writes them. +var mdlStatementNames = map[string]string{ + "If": "if", "EnumSplit": "case", "InheritanceSplit": "split type", "Loop": "loop", "While": "while", + "Break": "break", "Continue": "continue", "Return": "return", "RaiseError": "raise error", + "CastObject": "cast", "Rollback": "rollback", "CallJavaAction": "call java action", + "CallNanoflow": "call nanoflow", "CallJavaScriptAction": "call javascript action", + "CallExternalAction": "call external action", "CallWebService": "call web service", + "RestCall": "rest call", "SendRestRequest": "send rest request", "ImportFromMapping": "import from mapping", + "ExportToMapping": "export to mapping", "TransformJson": "transform json", + "ExecuteDatabaseQuery": "execute database query", "CreateList": "create list", "AddToList": "add to", + "RemoveFromList": "remove from", "ClearList": "clear", "Log": "log", "ShowPage": "show page", + "ClosePage": "close page", "ShowMessage": "show message", "ValidationFeedback": "validation feedback", + "SendEmail": "send email", "DownloadFile": "download file", +} + // expressionVariables lists the $variables an expression in Mendix's grammar // reads, in order, skipping string literals. The executor runs the same scan // before every evaluation (endpoint_exec.go), so the two cannot disagree about @@ -411,24 +474,38 @@ func firstUndeclared(expr string, declared map[string]bool) string { return "" } -// compileSuiteForExec compiles every test for the executor. It returns the -// reason the suite has to take the microflow route when one test cannot be -// compiled: a run takes one route, so a suite is either injected and built or -// not touched at all. A test whose assertions are already an ERROR does not -// decide the route; it is reported without running either way. -func compileSuiteForExec(suite *TestSuite) (map[string]*execProgram, string) { - out := make(map[string]*execProgram, len(suite.Tests)) +// compileSuiteForExec compiles every test it can for the executor. The others +// are returned with the reason each needs the microflow route: a run sends the +// first kind to the running app and injects and builds only the second, so one +// unsupported statement no longer costs the whole suite a build. A test whose +// assertions are already an ERROR is in neither; it is reported without running. +func compileSuiteForExec(suite *TestSuite) (programs map[string]*execProgram, needBuild map[string]string) { + programs = make(map[string]*execProgram, len(suite.Tests)) + needBuild = map[string]string{} for _, tc := range suite.Tests { if len(tc.AssertionErrors) > 0 { continue } p, reason := compileForExec(tc) if reason != "" { - return nil, fmt.Sprintf("test %q: %s", tc.Name, reason) + needBuild[tc.ID] = reason + continue + } + programs[tc.ID] = p + } + return programs, needBuild +} + +// suiteWith is suite with only the tests keep accepts. +func suiteWith(suite *TestSuite, keep func(TestCase) bool) *TestSuite { + sub := *suite + sub.Tests = nil + for _, tc := range suite.Tests { + if keep(tc) { + sub.Tests = append(sub.Tests, tc) } - out[tc.ID] = p } - return out, "" + return &sub } // staleModelError refuses to run tests straight against an app whose model on diff --git a/cmd/mxcli/testrunner/interp_test.go b/cmd/mxcli/testrunner/interp_test.go index 5e595c207..374eb535c 100644 --- a/cmd/mxcli/testrunner/interp_test.go +++ b/cmd/mxcli/testrunner/interp_test.go @@ -79,11 +79,13 @@ set $n = 1; // that names what kept it there. func TestCompileForExec_RoutesTheRestToMicroflows(t *testing.T) { for _, c := range []struct{ body, reason string }{ - {"if 1 = 1 then\n declare $x Integer = 1;\nend if;", "If"}, - {"loop $i in $list begin\nend loop;", "Loop"}, + {"if 1 = 1 then\n declare $x Integer = 1;\nend if;", "the statement if"}, + {"declare $list List of M.E = empty;\nloop $i in $list begin\nend loop;", "the statement loop"}, {"$r = call microflow M.F() on error continue;", "ON ERROR"}, {"retrieve $l from M.E sort by Name asc;", "sort"}, - {"retrieve $l from $o/M.Assoc;", "association"}, + {"$o = create M.E;\nretrieve $l from $o/M.Assoc where Name = 'x';", "over an association with where"}, + {"retrieve $l from M.E;\n$f = find $l by Name = 'x';", "list operation (find)"}, + {"$b = call java action M.J();", "the statement call java action"}, {"set $x = 1;", "$x is read before anything declares it"}, {"$r = call microflow M.F(A = $missing);", "$missing is read before"}, } { @@ -178,3 +180,71 @@ func TestEndpointExecJava(t *testing.T) { } } } + +// Aggregates written as statements and a retrieve over an association compile, +// so a suite of ordinary assertion helpers stays on the executor. +func TestCompileForExec_AggregatesAndAssociationRetrieve(t *testing.T) { + p, reason := compileOne(t, `mdl 1; +/** + * @test t + * @expect $n = 2 and $total > 0 + */ +retrieve $rows from M.Line; +$n = count($rows); +$total = sum($rows.Amount); +$o = create M.Order; +retrieve $lines from $o/M.Line_Order; +/ +`) + if reason != "" { + t.Fatalf("not interpretable: %s", reason) + } + n, total, lines := p.Steps[1], p.Steps[2], p.Steps[4] + if n.Op != "aggregate" || n.Agg != "count" || n.Of != "rows" || n.Var != "n" { + t.Errorf("count step = %+v", n) + } + if total.Op != "aggregate" || total.Agg != "sum" || total.Attr != "Amount" { + t.Errorf("sum step = %+v", total) + } + if lines.Op != "retrieve" || lines.Assoc != "M.Line_Order" || lines.From != "o" || lines.XPath != "" { + t.Errorf("association retrieve step = %+v", lines) + } +} + +// One test the executor cannot run sends only that test through inject and +// build; the others still run in the app. +func TestCompileSuiteForExec_RoutesPerTest(t *testing.T) { + tests, err := parseMDLTests(`mdl 1; +/** + * @test fast + * @expect $n = 1 + */ +declare $n Integer = 1; +/ + +/** + * @test slow + * @expect $n = 1 + */ +declare $n Integer = 0; +if true then + set $n = 1; +end if; +/ +`, "t.test.mdl") + if err != nil { + t.Fatal(err) + } + suite := &TestSuite{Name: "s", Tests: tests} + programs, needBuild := compileSuiteForExec(suite) + if len(programs) != 1 || programs[tests[0].ID] == nil { + t.Errorf("programs = %v, want only %s", programs, tests[0].ID) + } + if !strings.Contains(needBuild[tests[1].ID], "the statement if") || len(needBuild) != 1 { + t.Errorf("needBuild = %v", needBuild) + } + inject := suiteWith(suite, func(tc TestCase) bool { return programs[tc.ID] == nil }) + if len(inject.Tests) != 1 || inject.Tests[0].Name != "slow" || len(suite.Tests) != 2 { + t.Errorf("injected %d test(s) (%v), suite now holds %d", len(inject.Tests), inject.Tests, len(suite.Tests)) + } +} diff --git a/cmd/mxcli/testrunner/runner_attach.go b/cmd/mxcli/testrunner/runner_attach.go index 48cd85e85..a2279b563 100644 --- a/cmd/mxcli/testrunner/runner_attach.go +++ b/cmd/mxcli/testrunner/runner_attach.go @@ -176,34 +176,43 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. return result, err } - // An app started with --allow-call runs a compiled test itself, so a suite - // whose every test compiles is neither injected nor built (interp.go). + // An app started with --allow-call runs a compiled test itself (interp.go): + // those tests are neither injected nor built. Only the tests it cannot run + // are, so one unsupported statement no longer costs the whole suite a build. + toInject := suite if app.hs.AllowExec && !opts.Watch { - if programs, reason := compileSuiteForExec(suite); reason != "" { - fmt.Fprintf(w, " injecting and building, because %s\n", reason) - } else { + programs, needBuild := compileSuiteForExec(suite) + if len(programs) > 0 { if err := refuseStaleModel(opts.ProjectPath); err != nil { return nil, err } - direct := opts - direct.execPrograms = programs - result, err := runSuite(app.client, app.adminOptions(), suite, direct, w) + opts.execPrograms = programs + } + if len(needBuild) == 0 { + result, err := runSuite(app.client, app.adminOptions(), suite, opts, w) if err != nil { return nil, err } return report(result, nil) } + for _, tc := range suite.Tests { + if reason, ok := needBuild[tc.ID]; ok { + fmt.Fprintf(w, " injecting and building %q: %s\n", tc.Name, reason) + } + } + toInject = suiteWith(suite, func(tc TestCase) bool { return programs[tc.ID] == nil }) + injected = toInject } fmt.Fprintln(w, "Injecting test microflows...") - if err := execMDLScript(opts.ProjectPath, GenerateTestFlows(suite), "mxtest-flows-*.mdl"); err != nil { + if err := execMDLScript(opts.ProjectPath, GenerateTestFlows(toInject), "mxtest-flows-*.mdl"); err != nil { return finish(nil, fmt.Errorf("injecting test microflows: %w", err)) } if _, err := app.applyModelChange(opts.ProjectPath); err != nil { // Same treatment the --local boot gives a rejected build: attribute each // error to the generated microflow it was found in, and name the document // when it belongs to the project instead (mendixlabs/mxcli#1104). - return report(finish(resultsForBuildFailure(err, suite))) + return report(finish(resultsForBuildFailure(err, toInject))) } if opts.Watch { diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index 0144e5361..2a53444be 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -123,8 +123,16 @@ func runSuite(client *endpointClient, admin docker.M2EEOptions, suite *TestSuite // Ask the app which test microflows it actually has. A test whose microflow // is missing is reported as an error against that test rather than failing // the run. + // Tests without a compiled program run as generated microflows, so the app + // is asked which of those it has whenever there is at least one. + direct := 0 + for _, tc := range suite.Tests { + if opts.execPrograms[tc.ID] != nil { + direct++ + } + } present := make(map[string]bool) - if opts.execPrograms == nil { + if direct < len(suite.Tests) { names, err := client.list() if err != nil { return nil, fmt.Errorf("listing test microflows: %w", err) @@ -135,10 +143,14 @@ func runSuite(client *endpointClient, admin docker.M2EEOptions, suite *TestSuite } result := &SuiteResult{Name: suite.Name, Started: time.Now()} - if opts.execPrograms != nil { - fmt.Fprintf(w, "Running %d test(s) in the running app (no injection, no build)...\n", len(suite.Tests)) - } else { + switch { + case direct == 0: fmt.Fprintf(w, "Running %d test(s) over the test endpoint...\n", len(suite.Tests)) + case direct == len(suite.Tests): + fmt.Fprintf(w, "Running %d test(s) in the running app (no injection, no build)...\n", len(suite.Tests)) + default: + fmt.Fprintf(w, "Running %d test(s): %d in the running app, %d as built test microflows...\n", + len(suite.Tests), direct, len(suite.Tests)-direct) } // leaked counts tests whose requested rollback did not happen. diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index ddb1f7305..324db0212 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -205,14 +205,17 @@ mxcli test tests/ -p app.mpr --attach # terminal ``` You still write `.test.mdl`. The executor runs these statements: `declare`, -`set`, `create`, `change`, `commit`, `delete`, `call microflow`, and a database -`retrieve` with an optional `where` and `first`. It also handles `@expect` -(including `count(...)`), `@throws`, `@setup` and `@cleanup`. A test with -anything else (a decision, a loop, a retrieve over an association or with -`sort by`/`limit`, an `on error` clause other than `rollback`, a nanoflow or a -Java action call) makes the run take the usual route. mxcli prints which test and -which statement decided that, and the verdicts are the same on both routes. -The route is taken by a one-shot `--attach`; `test --attach --watch` still +`set`, `create`, `change`, `commit`, `delete`, `call microflow`, a database +`retrieve` with an optional `where` and `first`, a `retrieve` over an +association, and the list aggregates `count`, `sum`, `average`, `minimum` and +`maximum` over an attribute. It also handles `@expect` (including `count(...)`), +`@throws`, `@setup`, `@cleanup` and `@verify`. A test with anything else (a +decision, a loop, a list operation such as `find` or `filter`, a retrieve with +`sort by`/`limit`, an aggregate by expression, an `on error` clause other than +`rollback`, a nanoflow or Java action call) is injected and built as before. +Only that test is: the rest of the run still goes straight to the app, and mxcli +prints which test and which statement decided it. The verdicts are the same on +both routes. This applies to a one-shot `--attach`; `test --attach --watch` still injects and builds. Three things are checked before a test runs, because the runtime's evaluator @@ -230,7 +233,9 @@ returns *empty* for a name it does not know instead of failing: runs microflows the runner generated. With it, the endpoint calls the microflows a test names and reads and writes data in a system context. It still needs the run's token and a loopback caller, accepts only POST, and is off unless you pass -the flag. +the flag. Under `--hub` the loopback check does not help: the tunnel client +connects from loopback, so the token is the only guard, and `run` warns when both +flags are given. ## How tests execute diff --git a/mdl-examples/spikes/test-interpreter/app.mdl b/mdl-examples/spikes/test-interpreter/app.mdl new file mode 100644 index 000000000..dda91b756 --- /dev/null +++ b/mdl-examples/spikes/test-interpreter/app.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- Setup for differential.test.mdl: the microflows its tests call, added to the +-- app the evaluate-expression spike builds (../evaluate-expression.mdl). + +create java action Spike.Fail(Message: String) returns Boolean +as $$ +throw new com.mendix.core.CoreException(Message); +$$; + +create microflow Spike.ApplyDiscount (Amount: Decimal, Code: String) returns Decimal as $Result +begin + if $Amount < 0 then + $Failed = call java action Spike.Fail(Message = 'Amount must be positive'); + end if; + if $Code = 'SAVE10' then + return $Amount * 0.9; + end if; + return $Amount; +end; + +create microflow Spike.Invoice_Pay (Invoice: Spike.Invoice) +begin + change $Invoice (Status = Spike.Status.PAID); + commit $Invoice; +end; + +create microflow Spike.Seed () +begin + $C = create Spike.Customer (Name = 'Seeded') commit; +end; + +alter settings runtime ( AfterStartupMicroflow: '' ); diff --git a/mdl-examples/spikes/test-interpreter/differential.test.mdl b/mdl-examples/spikes/test-interpreter/differential.test.mdl new file mode 100644 index 000000000..1f63ccd9d --- /dev/null +++ b/mdl-examples/spikes/test-interpreter/differential.test.mdl @@ -0,0 +1,138 @@ +mdl 1; +-- Differential tests for the test runner's interpreter route (--allow-call). +-- +-- Every test here must get the same verdict, and the same failure message, on +-- both routes. The tests that FAIL do so on purpose: their message carries the +-- actual values, so comparing messages compares values. +-- +-- cd && ./mxcli exec app.mdl -p EvSpike.mpr +-- ./mxcli run --local --detach --watch --db-type hsqldb --test-endpoint --allow-call -p EvSpike.mpr +-- ./mxcli test differential.test.mdl -p EvSpike.mpr --attach > exec.out +-- ./mxcli run stop -p EvSpike.mpr +-- ./mxcli run --local --detach --watch --db-type hsqldb --test-endpoint -p EvSpike.mpr +-- ./mxcli test differential.test.mdl -p EvSpike.mpr --attach > built.out +-- diff the PASS/FAIL/ERROR lines of the two, durations aside. +-- +-- Measured on 11.12.1: identical. The first run of this comparison found that +-- Mendix's average over an empty list is empty, not 0; the executor now agrees. +-- The last test uses `if`, so with --allow-call it runs as a built microflow in +-- the same run as the rest (per-test routing). + +/** + * @test Discount applied for SAVE10 + * @expect $result = 90.0 + */ +$result = call microflow Spike.ApplyDiscount(Amount = 100.0, Code = 'SAVE10'); +/ + +/** + * @test A wrong expectation fails and shows the actual value + * @expect $result = 91.0 + */ +$result = call microflow Spike.ApplyDiscount(Amount = 100.0, Code = 'SAVE10'); +/ + +/** + * @test Negative amount is refused + * @throws 'Amount must be positive' + */ +$result = call microflow Spike.ApplyDiscount(Amount = -5.0, Code = 'SAVE10'); +/ + +/** + * @test A @throws with the wrong message fails + * @throws 'something else' + */ +$result = call microflow Spike.ApplyDiscount(Amount = -5.0, Code = 'SAVE10'); +/ + +/** + * @test Paying an invoice marks it paid + * @expect $inv/Status = Spike.Status.PAID + * @expect $inv/Spike.Invoice_Customer/Spike.Customer/Name = 'Acme' + */ +$cust = create Spike.Customer (Name = 'Acme'); +$inv = create Spike.Invoice (InvoiceNo = 'INV-1', Amount = 250.5, Qty = 3, Status = Spike.Status.OPEN, Spike.Invoice_Customer = $cust); +commit $cust; +call microflow Spike.Invoice_Pay(Invoice = $inv); +/ + +/** + * @test Retrieve and count see the test's own commits + * @setup Spike.Seed + * @expect count($paid) = 1 + * @expect $one/InvoiceNo = 'INV-9' + */ +$inv = create Spike.Invoice (InvoiceNo = 'INV-9', Status = Spike.Status.PAID) commit; +retrieve $paid from Spike.Invoice where Status = Spike.Status.PAID and InvoiceNo = $inv/InvoiceNo; +retrieve $one from Spike.Invoice where InvoiceNo = 'INV-9' first; +/ + +/** + * @test Variables, set and arithmetic + * @expect $n = 12 + */ +declare $n Integer = 5; +set $n = $n + 7; +/ + +/** + * @test Aggregates over two rows (shows the values) + * @expect toString($n) + '|' + toString($sumA) + '|' + toString($avgA) + '|' + toString($minA) + '|' + toString($maxQ) + '|' + toString($sumQ) + '|' + toString($avgQ) = 'show' + */ +$c = create Spike.Customer (Name = 'Agg') commit; +$i1 = create Spike.Invoice (InvoiceNo = 'AGG', Amount = 10.25, Qty = 2, Spike.Invoice_Customer = $c) commit; +$i2 = create Spike.Invoice (InvoiceNo = 'AGG', Amount = 4.5, Qty = 5, Spike.Invoice_Customer = $c) commit; +retrieve $rows from Spike.Invoice where InvoiceNo = 'AGG'; +$n = count($rows); +$sumA = sum($rows.Amount); +$avgA = average($rows.Amount); +$minA = minimum($rows.Amount); +$maxQ = maximum($rows.Qty); +$sumQ = sum($rows.Qty); +$avgQ = average($rows.Qty); +/ + +/** + * @test Aggregates over no rows (shows the values) + * @expect toString($n) + '|' + toString($sumA) + '|' + toString($avgA) + '|' + toString($minA) + '|' + toString($maxQ) + '|' + toString($sumQ) + '|' + toString($avgQ) = 'show' + */ +retrieve $rows from Spike.Invoice where InvoiceNo = 'NONE'; +$n = count($rows); +$sumA = sum($rows.Amount); +$avgA = average($rows.Amount); +$minA = minimum($rows.Amount); +$maxQ = maximum($rows.Qty); +$sumQ = sum($rows.Qty); +$avgQ = average($rows.Qty); +/ + +/** + * @test Owner side of a reference finds an uncommitted object + * @expect $cc/Name = 'Owner' + */ +$c = create Spike.Customer (Name = 'Owner'); +$i = create Spike.Invoice (InvoiceNo = 'ASSOC', Spike.Invoice_Customer = $c); +retrieve $cc from $i/Spike.Invoice_Customer; +/ + +/** + * @test Other side of a reference is the committed list + * @expect $k = 2 + */ +$c = create Spike.Customer (Name = 'Rev') commit; +$i1 = create Spike.Invoice (InvoiceNo = 'R1', Spike.Invoice_Customer = $c) commit; +$i2 = create Spike.Invoice (InvoiceNo = 'R2', Spike.Invoice_Customer = $c) commit; +retrieve $invs from $c/Spike.Invoice_Customer; +$k = count($invs); +/ + +/** + * @test A decision still runs, through inject and build + * @expect $n = 2 + */ +declare $n Integer = 1; +if $n = 1 then + set $n = 2; +end if; +/ From 49ec06a5b8645e56f786ee7af555e37fba323cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 19:36:26 +0000 Subject: [PATCH 10/16] fix(run): no phantom "change pending" under --test-endpoint, and a leftover endpoint is not committed unnoticed From the field test on ako/mxcli#2101. `run status` reported "change pending" for the whole life of a `run --local --watch --test-endpoint` session and `run wait` waited into its timeout. The boot build scaffolds themesource/mxtest/; the watcher already treats a build's own scaffold as output (ako/mxcli#2033), but the run state's readers compared the unfiltered signal. The scaffold is now recorded in run-state.json and status/wait use the watcher's signal (SourceMTimeOf); an edit to those files still counts. A run killed with its container left AfterStartupMicroflow pointing at MxTest.RegisterEndpoint until a `run stop` nobody ran. Any `mxcli run` now removes an abandoned endpoint before booting, and `run` and `check -p` warn while the setting is the endpoint's with no run hosting it: `run stop` with a restore record, the two commands that undo it by hand without one. Verified on an 11.12.1 app: no pending change after boot, run wait returns at once, an edit to the scaffolded folder still builds; a SIGKILLed hosted run is warned about by check -p and removed by the next plain run; without the restore record the manual commands clear it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- ...nt-scaffold-pending-and-leftover-swap.json | 1 + CHANGELOG.md | 1 + cmd/mxcli/cmd_check.go | 8 +++ cmd/mxcli/cmd_run.go | 13 +++++ cmd/mxcli/cmd_run_lifecycle.go | 10 ++-- cmd/mxcli/docker/runlocal.go | 2 + cmd/mxcli/docker/runlocal_scaffold_test.go | 47 ++++++++++++++++++ cmd/mxcli/docker/runstate.go | 27 ++++++++++ cmd/mxcli/testrunner/host_restore.go | 49 +++++++++++++++++++ cmd/mxcli/testrunner/host_restore_test.go | 48 ++++++++++++++++++ docs-site/src/tools/run-local.md | 2 +- 11 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-endpoint-scaffold-pending-and-leftover-swap.json diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-endpoint-scaffold-pending-and-leftover-swap.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-endpoint-scaffold-pending-and-leftover-swap.json new file mode 100644 index 000000000..2d6d8b1e0 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-endpoint-scaffold-pending-and-leftover-swap.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-10", "symptom": "Field test on ako/mxcli#2101: with `run --local --watch --test-endpoint`, `run status` said \"change pending\" for the whole session (5+ minutes) and nothing was ever built; `git status` showed the project settings modified, and a container recycle left AfterStartupMicroflow = MxTest.RegisterEndpoint in the project, where it could be committed.", "cause": "Two. The boot build scaffolds themesource/mxtest/ for the endpoint's module; the watcher excludes a build's own scaffold (sourceMTimeExcept, ako/mxcli#2033) but `run status` and `run wait` compared docker.SourceMTime, the unfiltered signal, with the boot's Last.Source, so they saw a change the watcher rightly never built (and `run wait` waited into its timeout). And only `run stop` and the next --test-endpoint run called RestoreAbandonedEndpoint, so a run killed with its container left the after-startup swap in place with nothing looking at it.", "file": "cmd/mxcli/docker/runstate.go (RunState.Scaffold, RunStateRecorder.Scaffold, SourceMTimeOf), cmd/mxcli/docker/runlocal.go (record the scaffold in the state), cmd/mxcli/cmd_run_lifecycle.go (status and wait use SourceMTimeOf), cmd/mxcli/testrunner/host_restore.go (LeftoverEndpointWarning), cmd/mxcli/cmd_run.go and cmd_check.go (restore and warn)", "insight": "A fix to a change signal has to reach every reader of that signal, not only the loop that reported the bug: #2033 fixed the watcher and left `run status`/`run wait`, which read the same question from the state file, on the old answer. Persist the filter with the state, so a reader cannot compute a different signal. The 'settings modified in git' symptom was a red herring for the pending change; the measurement that settled it was listing the files newer than Last.Source (all themesource/mxtest/). The test fails with SourceMTimeOf reduced to the unfiltered signal; on the spike app run status showed no pending change, run wait returned at once, an edit to the scaffolded folder still built, and a SIGKILLed hosted run was warned about by check -p and removed by the next plain run.", "refs": ["ako/mxcli#2101", "ako/mxcli#2033", "ako/mxcli#1917"], "test": "cmd/mxcli/docker/runlocal_scaffold_test.go, cmd/mxcli/testrunner/host_restore_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a37e0dd3..ca6823bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A dev loop hosting the test endpoint no longer reports a change it will never build, and a leftover endpoint is not committed unnoticed** (review on ako/mxcli#2101) — `run --local --watch --test-endpoint` showed "change pending" in `run status` for as long as it ran, and `run wait` waited into its timeout: the boot build scaffolds `themesource/mxtest/` for the endpoint's module, which the watcher already knew was build output (ako/mxcli#2033) but the run state's readers judged by the unfiltered signal. The scaffold is now recorded in `run-state.json`, and `run status` and `run wait` use the watcher's signal; an edit to those files still counts. A run killed with its container never removes the endpoint, so the project kept `AfterStartupMicroflow: 'MxTest.RegisterEndpoint'` until a `run stop` nobody ran. Any `mxcli run` now removes an abandoned endpoint before booting, not only the next `--test-endpoint` run, and `run` and `check -p` warn while the setting is still the endpoint's with no run hosting it, with `run stop` as the fix, or the two commands that undo it by hand when the restore record went too. - **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. - **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled. - **`alter page … insert into { row { column { … } } }` appends a row** (ako/mxcli#2071, mendixlabs/mxcli#1791) — it failed with "row needs a name", and with a name with "not a simple container", so a row could only be added by recreating the page. Rows are now built as layout grid rows and appended to the grid, as tab pages are to a tab container (#1215). A row has no stored name, so `insert before/after` cannot place one and says to use `insert into` the grid; `mxcli syntax page.alter` shows the form. diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index eefa1bf45..b1377de35 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -313,6 +313,14 @@ func runCheckFileAgainst(cmd *cobra.Command, filePath, against string) int { // - MDL067 is dropped for a flow already stored the way the script // commits — the filter exec's preflight applies. // - MDL-WORKFLOW10 reads a called microflow the script does not create. + // A test endpoint left in the project by a run that never removed it would + // be committed with the script's changes; say so while it can still be undone. + if projectPath != "" && !isStructured { + if msg := testrunner.LeftoverEndpointWarning(projectPath); msg != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", msg) + } + } + var exec *executor.Executor if projectPath != "" { e, logger := newLoggedExecutorTo("check", progressSink(format)) diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index 9022d74b7..661b16289 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -155,6 +155,19 @@ Examples: projectPath = abs } + // A test endpoint an earlier run left behind (killed with its container, + // say) is removed by any run, not only by the next --test-endpoint one, + // which does it while installing its own: the swapped after-startup + // setting otherwise stays in the project file until someone commits it. + if hosting, _ := cmd.Flags().GetBool("test-endpoint"); !hosting { + if _, err := testrunner.RestoreAbandonedEndpoint(projectPath, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + if msg := testrunner.LeftoverEndpointWarning(projectPath); msg != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", msg) + } + } + // Resolved before --detach as well as in the detached child, so a // missing variable or a world-readable file is reported here, in the // foreground, rather than in a log after the parent has returned. diff --git a/cmd/mxcli/cmd_run_lifecycle.go b/cmd/mxcli/cmd_run_lifecycle.go index cef857b7c..ae7e455d3 100644 --- a/cmd/mxcli/cmd_run_lifecycle.go +++ b/cmd/mxcli/cmd_run_lifecycle.go @@ -443,7 +443,7 @@ func runStatus(projectPath string, out io.Writer) int { fmt.Fprintf(out, "unknown: %v\n", err) return runExitFailed } - line, code := statusLine(st, liveStatusProbes(), docker.SourceMTime(projectPath), time.Now()) + line, code := statusLine(st, liveStatusProbes(), docker.SourceMTimeOf(projectPath, st), time.Now()) fmt.Fprintln(out, line) return code } @@ -455,7 +455,7 @@ func runStatusSummary(projectPath string) string { if err != nil || st == nil { return "" } - line, _ := statusLine(st, liveStatusProbes(), docker.SourceMTime(projectPath), time.Now()) + line, _ := statusLine(st, liveStatusProbes(), docker.SourceMTimeOf(projectPath, st), time.Now()) return line } @@ -614,7 +614,11 @@ func statusLine(st *docker.RunState, p statusProbes, source, now time.Time) (str // --- wait -------------------------------------------------------------- func runWait(projectPath string, timeout time.Duration, since int, out io.Writer) int { - target := docker.SourceMTime(projectPath) + // The run's own signal, without the theme scaffold its builds wrote: judged + // by the unfiltered one, a hosted dev loop's MxTest scaffold was a change no + // build would ever cover, and the wait ran into its timeout. + first, _ := docker.ReadRunState(projectPath) + target := docker.SourceMTimeOf(projectPath, first) deadline := time.Now().Add(timeout) for { st, err := docker.ReadRunState(projectPath) diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 08f0cede4..64d8256ad 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -848,6 +848,7 @@ func RunLocal(opts LocalRunOptions) error { return fmt.Errorf("initial build: %w", err) } scaffold.record(opts.ProjectPath, themeBefore, time.Now()) + opts.State.Scaffold(scaffold) if !build.OK() { return fmt.Errorf("initial build failed: %s\n%s", build.Message, buildFailureDetail(build)) } @@ -1580,6 +1581,7 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, b }) // A module created since the last build gets its theme scaffold now. scaffold.record(opts.ProjectPath, themeBefore, time.Now()) + opts.State.Scaffold(scaffold) if err != nil { fmt.Fprintf(opts.Stderr, " build error: %v\n", err) fail("build", err.Error(), nil) diff --git a/cmd/mxcli/docker/runlocal_scaffold_test.go b/cmd/mxcli/docker/runlocal_scaffold_test.go index abcfb5d1f..1c2337b9e 100644 --- a/cmd/mxcli/docker/runlocal_scaffold_test.go +++ b/cmd/mxcli/docker/runlocal_scaffold_test.go @@ -82,3 +82,50 @@ func TestBuildScaffoldRecordsOnlyNewFolders(t *testing.T) { t.Errorf("scaffold = %v, want only the folder the build created", s) } } + +// The readers of the run state — `run status`, `run wait` — compare the source +// against the build that covers it, and used the unfiltered signal: a hosted +// dev loop (`--test-endpoint`) reported "change pending" for as long as it ran, +// for the scaffold of its own MxTest module, and never built it, because the +// watcher (above) correctly does not count it. The scaffold the run recorded +// is in its state, so a reader filters exactly what the watcher filters. +func TestSourceMTimeOfRunStateIgnoresTheRecordedScaffold(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + t0 := time.Now().Add(-time.Hour) + touch(t, mpr, t0) + before := themeModuleFolders(mpr) + touch(t, filepath.Join(dir, "themesource", "mxtest", "settings.json"), t0.Add(time.Minute)) + builtAt := t0.Add(2 * time.Minute) + scaffold := buildScaffold{} + scaffold.record(mpr, before, builtAt) + + rec := NewRunStateRecorder(mpr, os.Getpid(), false, true, nil, "") + rec.Scaffold(scaffold) + st := rec.State() + + if !SourceMTime(mpr).After(t0) { + t.Fatal("control: the unfiltered signal should see the scaffold") + } + if got := SourceMTimeOf(mpr, &st); got.After(t0) { + t.Errorf("a reader of the run state sees the run's own scaffold as a change (%v after %v)", got, t0) + } + if got := SourceMTimeOf(mpr, nil); !got.Equal(SourceMTime(mpr)) { + t.Errorf("with no run state the signal is the unfiltered one, got %v", got) + } + + // The record survives the state file, as `run status` reads it from disk. + disk, err := ReadRunState(mpr) + if err != nil || disk == nil { + t.Fatalf("ReadRunState = %v, %v", disk, err) + } + if got := SourceMTimeOf(mpr, disk); got.After(t0) { + t.Errorf("the scaffold did not survive the state file: %v", disk.Scaffold) + } + + // A later edit to the scaffolded folder is still a change. + touch(t, filepath.Join(dir, "themesource", "mxtest", "settings.json"), builtAt.Add(time.Second)) + if !SourceMTimeOf(mpr, disk).After(t0) { + t.Error("a later edit to the scaffolded folder was ignored") + } +} diff --git a/cmd/mxcli/docker/runstate.go b/cmd/mxcli/docker/runstate.go index 6213cb9be..8f691e86d 100644 --- a/cmd/mxcli/docker/runstate.go +++ b/cmd/mxcli/docker/runstate.go @@ -116,6 +116,12 @@ type RunState struct { Gen int `json:"gen"` Building bool `json:"building"` BuildingSource time.Time `json:"buildingSource,omitempty"` + // Scaffold is the theme source the run's builds wrote themselves, by + // themesource/ module folder (see buildScaffold): files there no newer than + // the time are build output, not an edit. Recorded so a reader of this file + // (`run status`, `run wait`) judges "change pending" by the signal the + // watcher uses — SourceMTimeOf. + Scaffold map[string]time.Time `json:"scaffold,omitempty"` // Last is the newest finished generation. Last *BuildOutcome `json:"last,omitempty"` Updated time.Time `json:"updated"` @@ -256,6 +262,15 @@ func (r *RunStateRecorder) Ports(app, admin, serve int, runtimeLog string) { }) } +// Scaffold records the theme scaffold the run's builds have written. +func (r *RunStateRecorder) Scaffold(s buildScaffold) { + cp := make(map[string]time.Time, len(s)) + for k, v := range s { + cp[k] = v + } + r.update(func(st *RunState) { st.Scaffold = cp }) +} + // BuildStarted records that generation gen is being built from source. func (r *RunStateRecorder) BuildStarted(gen int, source time.Time) { r.update(func(s *RunState) { @@ -420,6 +435,18 @@ func FormatOutcome(o *BuildOutcome, maxLines int) string { // `run wait` measures a change with the same clock the watch loop builds from. func SourceMTime(projectPath string) time.Time { return sourceMTime(projectPath) } +// SourceMTimeOf is the change signal of the run st describes: SourceMTime +// without the theme scaffold its builds wrote, which is what its watcher +// compares. A hosted dev loop (`--test-endpoint`) scaffolds themesource/mxtest/ +// at boot, and judged by the unfiltered signal `run status` reported "change +// pending" for as long as it ran, a change the watcher rightly never built. +func SourceMTimeOf(projectPath string, st *RunState) time.Time { + if st == nil || len(st.Scaffold) == 0 { + return sourceMTime(projectPath) + } + return sourceMTimeExcept(projectPath, buildScaffold(st.Scaffold)) +} + // buildErrorLines turns a failed build into the lines a waiter is shown. func buildErrorLines(b *BuildResult, hint string) []string { var out []string diff --git a/cmd/mxcli/testrunner/host_restore.go b/cmd/mxcli/testrunner/host_restore.go index a19540b1e..f9b40a855 100644 --- a/cmd/mxcli/testrunner/host_restore.go +++ b/cmd/mxcli/testrunner/host_restore.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" + modelsdk "github.com/mendixlabs/mxcli" "github.com/mendixlabs/mxcli/cmd/mxcli/docker" ) @@ -142,3 +143,51 @@ func RestoreAbandonedEndpoint(projectPath string, w io.Writer) (bool, error) { fmt.Fprintln(w, " test endpoint removed; project restored") return true, nil } + +// afterStartupOf reads the project's after-startup microflow. A seam: the +// warning below is tested without a project file. +var afterStartupOf = func(projectPath string) (string, error) { + reader, err := modelsdk.Open(projectPath) + if err != nil { + return "", err + } + defer reader.Disconnect() + ps, err := reader.GetProjectSettings() + if err != nil || ps == nil || ps.Model == nil { + return "", err + } + return ps.Model.AfterStartupMicroflow, nil +} + +// LeftoverEndpointWarning says so when the project's after-startup microflow is +// still the test endpoint's and no run of this project is hosting it, and "" +// otherwise. +// +// --test-endpoint swaps the setting for the lifetime of its run, and puts it +// back when the run ends — but a run killed with the container (a cloud session +// being recycled) never gets to, and nothing else looks at the setting until a +// `run stop`. Meanwhile the project file carries the swap, and a commit takes it +// along. So `run` and `check -p` look, and say how to undo it: with a restore +// record, `run stop` restores exactly what was there; without one (the .mxcli/ +// folder went too), only the user knows what the setting was. +func LeftoverEndpointWarning(projectPath string) string { + rec, _, _ := readRestoreRecord(projectPath) + if rec != nil && rec.PID > 0 && pidAlive(rec.PID) { + return "" // a live run hosts it, and removes it when it ends + } + af, err := afterStartupOf(projectPath) + if err != nil || af != endpointStartupFlow { + return "" + } + if rec != nil { + return fmt.Sprintf("the project's after-startup microflow is %s, the mxcli test endpoint's, left by a run (pid %d) "+ + "that ended without removing it. Do not commit it:\n"+ + " mxcli run stop -p %s # removes the endpoint and restores the after-startup microflow", + endpointStartupFlow, rec.PID, projectPath) + } + return fmt.Sprintf("the project's after-startup microflow is %s, the mxcli test endpoint's, and no run of this "+ + "project is hosting it; nothing records what it replaced. Do not commit it: set your own back and drop the module:\n"+ + " mxcli -p %s -c \"alter settings runtime (AfterStartupMicroflow: '')\"\n"+ + " mxcli -p %s -c \"drop module %s\"", + endpointStartupFlow, projectPath, projectPath, mxTestModule) +} diff --git a/cmd/mxcli/testrunner/host_restore_test.go b/cmd/mxcli/testrunner/host_restore_test.go index af4a1ed3a..226709e93 100644 --- a/cmd/mxcli/testrunner/host_restore_test.go +++ b/cmd/mxcli/testrunner/host_restore_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" ) @@ -100,3 +101,50 @@ func TestRestoreAbandonedEndpoint_NoRecordIsNoOp(t *testing.T) { t.Fatalf("restored=%v err=%v, want false, nil", restored, err) } } + +func withAfterStartup(t *testing.T, af string) { + t.Helper() + old := afterStartupOf + afterStartupOf = func(string) (string, error) { return af, nil } + t.Cleanup(func() { afterStartupOf = old }) +} + +// A run killed with its container leaves the after-startup microflow pointing +// at the endpoint, and nothing said so until a `run stop` — so the swap could be +// committed. With the restore record still there, the fix is `run stop`. +func TestLeftoverEndpointWarning_DeadRunWithRecord(t *testing.T) { + mpr := recordedProject(t) + withRestoreSeams(t, false, nil) + withAfterStartup(t, endpointStartupFlow) + got := LeftoverEndpointWarning(mpr) + if !strings.Contains(got, endpointStartupFlow) || !strings.Contains(got, "mxcli run stop -p "+mpr) || !strings.Contains(got, "4242") { + t.Errorf("warning = %q", got) + } +} + +// Without a record (the .mxcli/ folder went too) only the user knows what the +// setting was, so the warning says how to set it rather than promising a restore. +func TestLeftoverEndpointWarning_NoRecord(t *testing.T) { + mpr := filepath.Join(t.TempDir(), "App.mpr") + withAfterStartup(t, endpointStartupFlow) + got := LeftoverEndpointWarning(mpr) + if !strings.Contains(got, "nothing records") || !strings.Contains(got, "alter settings runtime") || strings.Contains(got, "run stop") { + t.Errorf("warning = %q", got) + } +} + +// Controls: a live run's endpoint is in use, and a project whose after-startup +// is its own needs nothing said. +func TestLeftoverEndpointWarning_Controls(t *testing.T) { + mpr := recordedProject(t) + withRestoreSeams(t, true, nil) + withAfterStartup(t, endpointStartupFlow) + if got := LeftoverEndpointWarning(mpr); got != "" { + t.Errorf("a live run's endpoint was reported: %q", got) + } + withRestoreSeams(t, false, nil) + withAfterStartup(t, "MyFirstModule.ASU") + if got := LeftoverEndpointWarning(mpr); got != "" { + t.Errorf("a project's own after-startup was reported: %q", got) + } +} diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index 4db6fe2d3..d0010f7ba 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -178,7 +178,7 @@ would make Studio Pro 11.13 fail to open it — a branch with no upstream, or | `--screenshot-url` | app root | Page to shoot: full URL, or a path relative to the app root (e.g. `/p/customers`). Repeat for a multi-page set. | | `--screenshot-user` / `--screenshot-password` | — | Log in once (Mendix form auth) and reuse the session, so pages behind login render authenticated | | `--runtime-log` | `/.mxcli/runtime.log` | Runtime log file — JVM stdout/stderr **and** the application log (server stack traces + microflow `LOG` output); `-` disables | -| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so [`mxcli test … --attach`](running-tests.md) runs a suite against this app with no boot of its own. Installed before the boot (the handler registers from after-startup, so it cannot be added to a running app); your project's own after-startup microflow is chained, not displaced. Removed on exit. Tests then use **this app's database** | +| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so [`mxcli test … --attach`](running-tests.md) runs a suite against this app with no boot of its own. Installed before the boot (the handler registers from after-startup, so it cannot be added to a running app); your project's own after-startup microflow is chained, not displaced. Removed on exit; one left behind by a run that was killed (with its container, say) is removed by the next `run`, and `run` and `check -p` warn while the project's after-startup microflow is still `MxTest.RegisterEndpoint` with no run hosting it, so the swap is not committed. Tests then use **this app's database** | | `--debug` | off | Enable the microflow debugger at boot; then use [`mxcli debug`](debug-microflows.md) from another terminal. Behaviour-neutral until a breakpoint is set | | `--debug-pass` | `mxdebug` | Debugger password when `--debug` is set | | `--metrics` | off | Register a Prometheus meter registry; metrics served at `http://127.0.0.1:/prometheus` | From f82468fca44b8e44214911cee44c89c0ef225c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 20:00:26 +0000 Subject: [PATCH 11/16] fix(examples): write the differential test's aggregates in statement form The conformance gate refuses MDL-DEPR004 in example files: the call form `$n = count($rows)` is deprecated for `$n = count $rows` and `sum $rows by Amount`. Same aggregate, so the executor sees the same statement; the unit test uses the canonical form too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- cmd/mxcli/testrunner/interp_test.go | 4 +-- .../test-interpreter/differential.test.mdl | 30 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cmd/mxcli/testrunner/interp_test.go b/cmd/mxcli/testrunner/interp_test.go index 374eb535c..be8eb534d 100644 --- a/cmd/mxcli/testrunner/interp_test.go +++ b/cmd/mxcli/testrunner/interp_test.go @@ -190,8 +190,8 @@ func TestCompileForExec_AggregatesAndAssociationRetrieve(t *testing.T) { * @expect $n = 2 and $total > 0 */ retrieve $rows from M.Line; -$n = count($rows); -$total = sum($rows.Amount); +$n = count $rows; +$total = sum $rows by Amount; $o = create M.Order; retrieve $lines from $o/M.Line_Order; / diff --git a/mdl-examples/spikes/test-interpreter/differential.test.mdl b/mdl-examples/spikes/test-interpreter/differential.test.mdl index 1f63ccd9d..a01d16533 100644 --- a/mdl-examples/spikes/test-interpreter/differential.test.mdl +++ b/mdl-examples/spikes/test-interpreter/differential.test.mdl @@ -84,13 +84,13 @@ $c = create Spike.Customer (Name = 'Agg') commit; $i1 = create Spike.Invoice (InvoiceNo = 'AGG', Amount = 10.25, Qty = 2, Spike.Invoice_Customer = $c) commit; $i2 = create Spike.Invoice (InvoiceNo = 'AGG', Amount = 4.5, Qty = 5, Spike.Invoice_Customer = $c) commit; retrieve $rows from Spike.Invoice where InvoiceNo = 'AGG'; -$n = count($rows); -$sumA = sum($rows.Amount); -$avgA = average($rows.Amount); -$minA = minimum($rows.Amount); -$maxQ = maximum($rows.Qty); -$sumQ = sum($rows.Qty); -$avgQ = average($rows.Qty); +$n = count $rows; +$sumA = sum $rows by Amount; +$avgA = average $rows by Amount; +$minA = minimum $rows by Amount; +$maxQ = maximum $rows by Qty; +$sumQ = sum $rows by Qty; +$avgQ = average $rows by Qty; / /** @@ -98,13 +98,13 @@ $avgQ = average($rows.Qty); * @expect toString($n) + '|' + toString($sumA) + '|' + toString($avgA) + '|' + toString($minA) + '|' + toString($maxQ) + '|' + toString($sumQ) + '|' + toString($avgQ) = 'show' */ retrieve $rows from Spike.Invoice where InvoiceNo = 'NONE'; -$n = count($rows); -$sumA = sum($rows.Amount); -$avgA = average($rows.Amount); -$minA = minimum($rows.Amount); -$maxQ = maximum($rows.Qty); -$sumQ = sum($rows.Qty); -$avgQ = average($rows.Qty); +$n = count $rows; +$sumA = sum $rows by Amount; +$avgA = average $rows by Amount; +$minA = minimum $rows by Amount; +$maxQ = maximum $rows by Qty; +$sumQ = sum $rows by Qty; +$avgQ = average $rows by Qty; / /** @@ -124,7 +124,7 @@ $c = create Spike.Customer (Name = 'Rev') commit; $i1 = create Spike.Invoice (InvoiceNo = 'R1', Spike.Invoice_Customer = $c) commit; $i2 = create Spike.Invoice (InvoiceNo = 'R2', Spike.Invoice_Customer = $c) commit; retrieve $invs from $c/Spike.Invoice_Customer; -$k = count($invs); +$k = count $invs; / /** From ee128c3d772d70b9897088d39667416fe286a8a7 Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 20:04:42 +0000 Subject: [PATCH 12/16] fix(mpr): keep the v1 Unit table scan and unit names between listings (#2094) On an MPR v1 project every typed listing rescanned the metadata of the whole Unit table, and every lookup by name read the contents of every unit of the type, so exec and check paid 0.3-0.9 s per statement on a 3,400-unit project whatever the statement changed. The reader now keeps the scan until a write through it (InvalidateCache, which v1 updateUnit now calls too) or a commit by another connection (PRAGMA data_version; file size and mtime as a second signal) says the table may have changed. Unit names are kept by length and ContentsHash, and dropped wholesale on a foreign commit. Co-Authored-By: Claude Opus 5.5 --- ...1-listing-rescans-unit-table-per-call.json | 1 + CHANGELOG.md | 1 + .../bug-tests/2094-lookups-after-writes.mdl | 48 ++++ modelsdk/mpr/reader.go | 18 +- modelsdk/mpr/reader_units.go | 12 +- modelsdk/mpr/reader_units_v1.go | 217 ++++++++++++++--- modelsdk/mpr/reader_units_v1_cache_test.go | 219 ++++++++++++++++++ modelsdk/mpr/reader_units_v1_test.go | 11 +- modelsdk/mpr/writer_core.go | 3 + 9 files changed, 485 insertions(+), 45 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/modelsdk/2026-11-10-v1-listing-rescans-unit-table-per-call.json create mode 100644 mdl-examples/bug-tests/2094-lookups-after-writes.mdl create mode 100644 modelsdk/mpr/reader_units_v1_cache_test.go diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-11-10-v1-listing-rescans-unit-table-per-call.json b/.claude/skills/fix-issue/findings/modelsdk/2026-11-10-v1-listing-rescans-unit-table-per-call.json new file mode 100644 index 000000000..83c312a9d --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-11-10-v1-listing-rescans-unit-table-per-call.json @@ -0,0 +1 @@ +{"area": "modelsdk/mpr", "date": "2026-11-10", "symptom": "ako/mxcli#2094 (mendixlabs/mxcli#1795): on a large MPR v1 project exec took 0.3 s per `create microflow` and 0.9 s per one-activity `create or modify`, check 0.5 s per statement, whatever the statement changed; the cost was fixed per statement, not growing with the run, and v2 projects of similar shape were an order of magnitude cheaper.", "cause": "listUnitsByTypeV1In rescanned the metadata of the whole Unit table on every typed listing to revalidate its type index (v1 updateUnit did not invalidate, so the scan was the only change detector), and a statement makes dozens of listings; ListUnitHeaders on v1 read the full contents of every unit of the type to get the names, so each lookup by name read every microflow.", "file": "modelsdk/mpr/reader_units_v1.go (unitMetasV1 + v1Stamp keep the scan; listUnitHeadersV1 keeps Name/Excluded by length + ContentsHash), modelsdk/mpr/reader_units.go (InvalidateCache reaches it; ListUnitHeaders v1 path), modelsdk/mpr/writer_core.go (v1 updateUnit invalidates like every other write)", "insight": "Profile before guessing: a CPU profile of 50 statements put 54% in listUnitsByTypeV1 and showed exec's preflight doing the same planning first. The reader holds ONE SQLite connection (SetMaxOpenConns(1)), which makes PRAGMA data_version an exact detector for commits by any other connection or process — its own writes do not move it, so they invalidate explicitly; file size+mtime is only a second signal, because on Windows they can lag for an open file. A unit's $Type is fixed for life but its Name is not, so names are keyed on length AND ContentsHash, and a foreign commit drops them all since nothing obliges another writer to set the hash. Test with the file-stat signal off (reader path empty), or the stat masks a missing invalidation. Measured on OneHarness (3,400 units): create 0.30 -> 0.11 s, create or modify 0.86 -> 0.35 s, check of 150 statements 76 -> 15 s. What remains is backend-level: ListDomainModels and ListMicroflows decode every unit per call, and the preflight plans each flow change twice.", "refs": ["ako/mxcli#2094", "mendixlabs/mxcli#1795", "ako/mxcli#2043", "ako/mxcli#1159"], "test": "modelsdk/mpr/reader_units_v1_cache_test.go, mdl-examples/bug-tests/2094-lookups-after-writes.mdl"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a37e0dd3..efd9d6d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`exec` and `check` no longer rescan an MPR v1 project for every lookup** (ako/mxcli#2094, mendixlabs/mxcli#1795) — each statement cost 0.3–0.9 s on a large single-file project, whatever it changed: every typed listing re-read the metadata of the whole `Unit` table, and every lookup by name read every microflow's contents. The reader now keeps the scan, and each unit's name, until a write or another process's commit says the table may have changed. On a 3,400-unit project a `create microflow` went from 0.30 s to 0.11 s, a one-activity `create or modify` from 0.86 s to 0.35 s, and `check` of 150 such statements from 76 s to 15 s. MPR v2 projects already kept this index. - **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. - **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled. - **`alter page … insert into { row { column { … } } }` appends a row** (ako/mxcli#2071, mendixlabs/mxcli#1791) — it failed with "row needs a name", and with a name with "not a simple container", so a row could only be added by recreating the page. Rows are now built as layout grid rows and appended to the grid, as tab pages are to a tab container (#1215). A row has no stored name, so `insert before/after` cannot place one and says to use `insert into` the grid; `mxcli syntax page.alter` shows the form. diff --git a/mdl-examples/bug-tests/2094-lookups-after-writes.mdl b/mdl-examples/bug-tests/2094-lookups-after-writes.mdl new file mode 100644 index 000000000..a458528de --- /dev/null +++ b/mdl-examples/bug-tests/2094-lookups-after-writes.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- ako/mxcli#2094 (mendixlabs/mxcli#1795): exec and check took 0.3–0.9 s per +-- statement on a large MPR v1 project, whatever the statement changed. Every +-- typed listing rescanned the whole Unit table, and every lookup by name read +-- every microflow's contents. The reader now keeps the scan and each unit's +-- name until a write or another process's commit says they may have changed. +-- +-- This script checks the kept listing follows the script's own writes: a +-- microflow renamed to a name of the SAME LENGTH (so the blob length does not +-- move) must be found by its new name by the very next statement, and no +-- longer by its old one. +-- +-- Run on a fresh app (MPR v1 or v2): +-- mxcli exec mdl-examples/bug-tests/2094-lookups-after-writes.mdl -p App.mpr +-- Expected: every statement succeeds, and +-- mxcli -p App.mpr -c "describe microflow Bug2094.Caller" +-- shows `call microflow Bug2094.Get_B()`. +-- With a listing that ignored the script's own writes, the call to Get_B +-- failed as an unknown microflow (or `drop microflow if exists Bug2094.Get_A` +-- dropped the renamed one). + +create module Bug2094; + +create persistent entity Bug2094.Cfg (Name: String(100)); + +create microflow Bug2094.Get_A () returns Bug2094.Cfg +begin + retrieve $c from Bug2094.Cfg first; + return $c; +end; + +rename microflow Bug2094.Get_A to Get_B; + +-- Found by the new name in the next statement. +create or modify microflow Bug2094.Get_B () returns Bug2094.Cfg +begin + retrieve $c from Bug2094.Cfg first; + log info node 'Bug2094' 'got it'; + return $c; +end; + +-- And no longer by the old one: this must skip, not drop Get_B. +drop microflow if exists Bug2094.Get_A; + +create microflow Bug2094.Caller () +begin + $c = call microflow Bug2094.Get_B(); +end; diff --git a/modelsdk/mpr/reader.go b/modelsdk/mpr/reader.go index 577e075e8..0961516c3 100644 --- a/modelsdk/mpr/reader.go +++ b/modelsdk/mpr/reader.go @@ -84,16 +84,30 @@ type Reader struct { // v1Types is MPR v1's unit-type index (reader_units_v1.go): UnitID blob → // $Type, with the blob length it was read at. It lets a typed listing - // skip the contents of every other unit. Revalidated on every listing, so - // it needs no invalidation from the writer. + // skip the contents of every other unit. Revalidated against each fresh + // scan of the Unit table. v1TypesMu sync.Mutex v1Types map[string]v1TypeEntry + // v1Metas is the last scan of the Unit table's metadata, kept while + // v1MetasValid and v1MetasStamp say the table cannot have changed + // (unitMetasV1). v1Headers is each unit's Name / Excluded by length and + // ContentsHash (listUnitHeadersV1). v1NoHashColumn marks a project whose + // Unit table has no ContentsHash. All guarded by v1TypesMu. + v1Metas []v1UnitMeta + v1MetasValid bool + v1MetasStamp v1Stamp + v1Headers map[string]v1HeaderEntry + v1NoHashColumn bool // blobReads counts full unit-content blobs read from the v1 Unit table by // listings — the cost a typed listing must keep proportional to its // result, not to the project. Read by tests. blobReads atomic.Int64 + // metaScans counts scans of the v1 Unit table's metadata — what the + // listing cache saves (ako/mxcli#2094). Read by tests. + metaScans atomic.Int64 + // packaged is what the project's module packages (modules/*.mxmodule) // hold, read on first use (packaged_modules.go). packagedOnce sync.Once diff --git a/modelsdk/mpr/reader_units.go b/modelsdk/mpr/reader_units.go index 09b2c6fc1..3958fa49c 100644 --- a/modelsdk/mpr/reader_units.go +++ b/modelsdk/mpr/reader_units.go @@ -109,16 +109,7 @@ func (r *Reader) ListUnitHeaders(typeName string) ([]UnitHeaderRef, error) { // listStoredUnitHeaders is ListUnitHeaders for the units the .mpr stores. func (r *Reader) listStoredUnitHeaders(typeName string) ([]UnitHeaderRef, error) { if r.version != MPRVersionV2 { - units, err := r.listUnitsByType(typeName) - if err != nil { - return nil, err - } - out := make([]UnitHeaderRef, 0, len(units)) - for _, u := range units { - e := unitIndexEntryOf(u.Contents) - out = append(out, UnitHeaderRef{ID: u.ID, ContainerID: u.ContainerID, Name: e.Name, Excluded: e.Excluded}) - } - return out, nil + return r.listUnitHeadersV1(typeName) } if !r.unitCacheValid { if err := r.buildUnitCache(); err != nil { @@ -439,6 +430,7 @@ func (r *Reader) buildUnitCache() error { // Should be called after any write operation. func (r *Reader) InvalidateCache() { r.unitCacheValid = false + r.invalidateV1Listing() r.clearNameIndex() // Clear content cache entries but keep the map non-nil so caching stays active. // If contentCache is nil (per-request mode), remain disabled. diff --git a/modelsdk/mpr/reader_units_v1.go b/modelsdk/mpr/reader_units_v1.go index 917bb797f..e5e9a3cbd 100644 --- a/modelsdk/mpr/reader_units_v1.go +++ b/modelsdk/mpr/reader_units_v1.go @@ -7,6 +7,8 @@ import ( "database/sql" "encoding/binary" "fmt" + "os" + "strings" "go.mongodb.org/mongo-driver/v2/x/bsonx/bsoncore" ) @@ -21,15 +23,24 @@ import ( // microflow`. MPR v2 never paid this, because buildUnitCache indexes the types // once and listUnitsByTypeV2 then reads only the matching files. // -// This is the v1 counterpart of that index, with one difference: v1's -// updateUnit writes the Unit table without calling InvalidateCache, and -// another process can change the file under a long-lived reader, so the index -// cannot rely on being invalidated. Instead every listing re-reads the cheap -// columns (ID, container, containment name and length(Contents) — the length -// comes from the record header, no overflow page is touched) and re-types any -// unit that is new or whose length moved. A unit's $Type is fixed for its -// lifetime in Mendix, so the length is a belt-and-braces change detector, not -// the thing correctness rests on. +// This is the v1 counterpart of that index. A listing reads the cheap columns +// of every row (ID, container, containment name, length(Contents) — the length +// comes from the record header, no overflow page is touched — and +// ContentsHash) and re-types any unit that is new or whose length moved. A +// unit's $Type is fixed for its lifetime in Mendix, so the length is a +// belt-and-braces change detector, not the thing correctness rests on. +// +// That scan is kept until the table may have changed (unitMetasV1): a write +// through this reader invalidates it, and a commit by any other connection — +// another process, Studio Pro — moves SQLite's data_version. Scanning on every +// listing instead cost 0.3–0.9 s per `exec` statement on a 3,400-unit project, +// because one statement makes dozens of listings (ako/mxcli#2094). +// +// Name and Excluded are kept per unit too (listUnitHeadersV1), keyed by the +// row's length and ContentsHash, which every Studio Pro and mxcli write +// updates. A unit without a hash is read every time, and a commit from another +// connection drops every kept name, since nothing obliges another writer to +// update the hash. // // The type itself is peeked from the blob's first v1TypePeekBytes bytes: // Studio Pro writes `$ID` then `$Type` at the head of every unit. A document @@ -55,7 +66,8 @@ type v1UnitMeta struct { key string // string(blob): the type index key containerBlob []byte containmentName string - size int64 // -1 for NULL contents + size int64 // -1 for NULL contents + hash string // ContentsHash; "" when NULL or the column is absent held []byte overlaid bool } @@ -75,13 +87,12 @@ func (r *Reader) listUnitsByTypeV1In(typeName, containerID string) ([]rawUnit, e return nil, fmt.Errorf("listUnitsByTypeV1In: empty type name") } - metas, err := r.scanUnitMetaV1() + r.v1TypesMu.Lock() + defer r.v1TypesMu.Unlock() + metas, err := r.unitMetasV1() if err != nil { return nil, err } - - r.v1TypesMu.Lock() - defer r.v1TypesMu.Unlock() if err := r.refreshTypeIndexV1(metas); err != nil { return nil, err } @@ -148,12 +159,12 @@ func (r *Reader) listUnitsByTypeV1In(typeName, containerID string) ([]rawUnit, e // listUnitTypesV1 returns every unit's metadata and type without its // contents (Contents is nil), in table order. func (r *Reader) listUnitTypesV1() ([]rawUnit, error) { - metas, err := r.scanUnitMetaV1() + r.v1TypesMu.Lock() + defer r.v1TypesMu.Unlock() + metas, err := r.unitMetasV1() if err != nil { return nil, err } - r.v1TypesMu.Lock() - defer r.v1TypesMu.Unlock() if err := r.refreshTypeIndexV1(metas); err != nil { return nil, err } @@ -205,13 +216,85 @@ func (r *Reader) listAllUnitsV1() ([]rawUnit, error) { return units, rows.Err() } -// scanUnitMetaV1 reads every unit's metadata, without its contents, in table -// order (the order the full scan returned units in). +// v1Stamp is what says the Unit table may have changed since a scan. +// dataVersion is SQLite's PRAGMA data_version, which moves when another +// connection commits to the file; the reader holds one connection +// (SetMaxOpenConns(1)), so its own writes do not move it and invalidate +// explicitly instead. The file's size and modification time are a second +// signal, for a write made on this connection without that invalidation. +type v1Stamp struct { + dataVersion int64 + size int64 + modNanos int64 +} + +// v1StampNow reads the current stamp; ok is false when it cannot be read, and +// the caller then scans as if the table had changed. +func (r *Reader) v1StampNow() (v1Stamp, bool) { + var s v1Stamp + if err := r.db.QueryRow(`PRAGMA data_version`).Scan(&s.dataVersion); err != nil { + return v1Stamp{}, false + } + s.size, s.modNanos = -1, -1 + if r.path != "" { + if fi, err := os.Stat(r.path); err == nil { + s.size, s.modNanos = fi.Size(), fi.ModTime().UnixNano() + } + } + return s, true +} + +// invalidateV1Listing marks the kept Unit table scan stale after a write +// through this reader. The kept names survive: they are checked against each +// row's length and hash, which every write path updates. +func (r *Reader) invalidateV1Listing() { + r.v1TypesMu.Lock() + r.v1MetasValid = false + r.v1TypesMu.Unlock() +} + +// unitMetasV1 is every unit's metadata in table order: the kept scan while the +// table cannot have changed, else a fresh one. Overlaid units carry their held +// bytes. The returned slice is the caller's. Caller holds v1TypesMu. +func (r *Reader) unitMetasV1() ([]v1UnitMeta, error) { + stamp, stampOK := r.v1StampNow() + if !r.v1MetasValid || !stampOK || stamp != r.v1MetasStamp { + if r.v1MetasValid && stamp.dataVersion != r.v1MetasStamp.dataVersion { + // Another connection committed: its writes need not have moved + // ContentsHash, so no kept name can be trusted. + clear(r.v1Headers) + } + metas, err := r.scanUnitMetaV1() + if err != nil { + r.v1MetasValid = false + return nil, err + } + r.v1Metas, r.v1MetasStamp, r.v1MetasValid = metas, stamp, stampOK + } + out := make([]v1UnitMeta, len(r.v1Metas)) + copy(out, r.v1Metas) + if len(r.overlay) > 0 { + for i := range out { + out[i].held, out[i].overlaid = r.overlaid(blobToUUID(out[i].blob)) + } + } + return out, nil +} + +// scanUnitMetaV1 reads every unit's metadata, without its contents or any +// overlay, in table order (the order the full scan returned units in). func (r *Reader) scanUnitMetaV1() ([]v1UnitMeta, error) { - rows, err := r.db.Query(` - SELECT UnitID, ContainerID, ContainmentName, length(Contents) - FROM Unit - `) + r.metaScans.Add(1) + hashCol := "ContentsHash" + if r.v1NoHashColumn { + hashCol = "NULL" + } + rows, err := r.db.Query(`SELECT UnitID, ContainerID, ContainmentName, length(Contents), ` + hashCol + ` FROM Unit`) + if err != nil && !r.v1NoHashColumn && strings.Contains(err.Error(), "no such column") { + // A project from before Studio Pro stored ContentsHash. + r.v1NoHashColumn = true + return r.scanUnitMetaV1() + } if err != nil { return nil, fmt.Errorf("failed to query units: %w", err) } @@ -220,9 +303,9 @@ func (r *Reader) scanUnitMetaV1() ([]v1UnitMeta, error) { var metas []v1UnitMeta for rows.Next() { var unitID, containerID []byte - var containmentName sql.NullString + var containmentName, hash sql.NullString var size sql.NullInt64 - if err := rows.Scan(&unitID, &containerID, &containmentName, &size); err != nil { + if err := rows.Scan(&unitID, &containerID, &containmentName, &size, &hash); err != nil { return nil, fmt.Errorf("failed to scan unit row: %w", err) } m := v1UnitMeta{ @@ -231,18 +314,96 @@ func (r *Reader) scanUnitMetaV1() ([]v1UnitMeta, error) { containerBlob: containerID, containmentName: containmentName.String, size: -1, + hash: hash.String, } if size.Valid { m.size = size.Int64 } - if len(r.overlay) > 0 { - m.held, m.overlaid = r.overlaid(blobToUUID(unitID)) - } metas = append(metas, m) } return metas, rows.Err() } +// v1HeaderEntry is a unit's Name and Excluded, as read at a length and hash. +type v1HeaderEntry struct { + size int64 + hash string + name string + excluded bool +} + +// listUnitHeadersV1 is ListUnitHeaders on MPR v1: a unit's blob is read only +// when its name is not kept for its current length and hash. Naming every +// microflow used to read every microflow's contents, on each lookup by name +// (ako/mxcli#2094). +func (r *Reader) listUnitHeadersV1(typeName string) ([]UnitHeaderRef, error) { + r.v1TypesMu.Lock() + defer r.v1TypesMu.Unlock() + metas, err := r.unitMetasV1() + if err != nil { + return nil, err + } + if err := r.refreshTypeIndexV1(metas); err != nil { + return nil, err + } + if r.v1Headers == nil { + r.v1Headers = make(map[string]v1HeaderEntry) + } + var stmt *sql.Stmt + defer func() { + if stmt != nil { + _ = stmt.Close() + } + }() + var out []UnitHeaderRef + for _, m := range metas { + ref := func(name string, excluded bool) UnitHeaderRef { + return UnitHeaderRef{ID: blobToUUID(m.blob), ContainerID: blobToUUID(m.containerBlob), Name: name, Excluded: excluded} + } + if m.overlaid { + e := unitIndexEntryOf(m.held) + if e.Type == typeName { + out = append(out, ref(e.Name, e.Excluded)) + } + continue + } + if r.v1Types[m.key].typ != typeName { + continue + } + if h, ok := r.v1Headers[m.key]; ok && m.hash != "" && h.hash == m.hash && h.size == m.size { + out = append(out, ref(h.name, h.excluded)) + continue + } + if stmt == nil { + if stmt, err = r.db.Prepare(`SELECT Contents FROM Unit WHERE UnitID = ?`); err != nil { + return nil, fmt.Errorf("failed to prepare unit read: %w", err) + } + } + var contents []byte + if err := stmt.QueryRow(m.blob).Scan(&contents); err != nil { + if err == sql.ErrNoRows { + continue // deleted since the metadata scan + } + return nil, fmt.Errorf("failed to read unit %s: %w", blobToUUID(m.blob), err) + } + r.blobReads.Add(1) + e := unitIndexEntryOf(contents) + if int64(len(contents)) != m.size { + // Rewritten since the metadata scan: type what was read, and keep + // no name for a row whose hash was not read with it. + r.v1Types[m.key] = v1TypeEntry{typ: e.Type, size: int64(len(contents))} + delete(r.v1Headers, m.key) + if e.Type != typeName { + continue + } + } else if m.hash != "" { + r.v1Headers[m.key] = v1HeaderEntry{size: m.size, hash: m.hash, name: e.Name, excluded: e.Excluded} + } + out = append(out, ref(e.Name, e.Excluded)) + } + return out, nil +} + // refreshTypeIndexV1 types every unit in metas that the index does not hold at // its current length, and drops units that no longer exist. Caller holds // v1TypesMu. diff --git a/modelsdk/mpr/reader_units_v1_cache_test.go b/modelsdk/mpr/reader_units_v1_cache_test.go new file mode 100644 index 000000000..df10a2c79 --- /dev/null +++ b/modelsdk/mpr/reader_units_v1_cache_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "bytes" + "database/sql" + "fmt" + "path/filepath" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ako/mxcli#2094 (mendixlabs/mxcli#1795): `exec` and `check` took 0.3–0.9 s +// per statement on a v1 project of 3,400 units, whatever the statement +// changed. Every typed listing re-read the metadata of the whole Unit table to +// revalidate the type index, and a statement makes many listings; a lookup by +// name (ListUnitHeaders) read the full contents of every unit of the type. The +// table is now scanned again only when it may have changed — a write through +// this reader, or a commit by another connection or process — and a unit's +// Name / Excluded are read once per version of its contents. + +// cachedListingFixture is newTypedListingFixture with a ContentsHash on every +// unit, as a real project has (Studio Pro fills it on every v1 unit, +// ako/mxcli#1159). +func cachedListingFixture(t *testing.T, types []string) (*typedListingFixture, string) { + t.Helper() + f := newTypedListingFixture(t, types) + path := f.r.path + // Without the path the file-stat signal is off, so these tests prove the + // other two on their own: data_version for another connection's commit, + // InvalidateCache for this reader's writes. (On Windows a file's size and + // time can lag while it is open; data_version does not.) + f.r.path = "" + rows, err := f.db.Query(`SELECT UnitID, Contents FROM Unit`) + if err != nil { + t.Fatal(err) + } + type row struct{ id, contents []byte } + var all []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.contents); err != nil { + t.Fatal(err) + } + all = append(all, r) + } + rows.Close() + for _, r := range all { + if _, err := f.db.Exec(`UPDATE Unit SET ContentsHash = ? WHERE UnitID = ?`, contentsHashOf(r.contents), r.id); err != nil { + t.Fatal(err) + } + } + return f, path +} + +// otherProcess opens the project file on a connection of its own, as Studio +// Pro or a second mxcli would. +func otherProcess(t *testing.T, path string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func microflowIDs(t *testing.T, r *Reader) []string { + t.Helper() + units, err := r.listUnitsByType("Microflows$Microflow") + if err != nil { + t.Fatal(err) + } + var ids []string + for _, u := range units { + ids = append(ids, u.ID) + } + return ids +} + +func TestListUnitsByTypeV1ScansTableOncePerChange_Issue2094(t *testing.T) { + f, path := cachedListingFixture(t, fixtureTypes(10, 3, "Microflows$Microflow", "Forms$Page")) + + before := f.r.metaScans.Load() + for range 3 { + microflowIDs(t, f.r) + if _, err := f.r.listUnitTypesV1(); err != nil { + t.Fatal(err) + } + } + if scans := f.r.metaScans.Load() - before; scans != 1 { + t.Errorf("six listings of an unchanged table scanned it %d times; want 1", scans) + } + + // A commit by another connection is seen by the next listing. + other := otherProcess(t, path) + if _, err := other.Exec(`INSERT INTO Unit (UnitID, ContainerID, ContainmentName, Contents) VALUES (?, ?, ?, ?)`, + uuidToBlob(v1UnitID(50)), uuidToBlob(v1UnitID(0)), "Documents", v1UnitContents(t, 50, "Microflows$Microflow", 10)); err != nil { + t.Fatal(err) + } + want := fmt.Sprint([]string{v1UnitID(1), v1UnitID(4), v1UnitID(7), v1UnitID(10), v1UnitID(50)}) + if got := fmt.Sprint(microflowIDs(t, f.r)); got != want { + t.Errorf("after another process inserted a microflow: got %s, want %s", got, want) + } + + // So is a write through this reader's own connection followed by the + // invalidation every writer path makes. + if _, err := f.db.Exec(`DELETE FROM Unit WHERE UnitID = ?`, uuidToBlob(v1UnitID(4))); err != nil { + t.Fatal(err) + } + f.r.InvalidateCache() + want = fmt.Sprint([]string{v1UnitID(1), v1UnitID(7), v1UnitID(10), v1UnitID(50)}) + if got := fmt.Sprint(microflowIDs(t, f.r)); got != want { + t.Errorf("after deleting a microflow: got %s, want %s", got, want) + } +} + +// renamed is unit i's fixture document (v1UnitContents, pad 8000) with a +// different Name of the same length, so the blob length — the type index's +// change detector — does not move. +func renamed(t *testing.T, i int, typeName, name string) []byte { + t.Helper() + b, err := bson.Marshal(bson.D{ + {Key: "$ID", Value: bson.Binary{Subtype: 0, Data: uuidToBlob(v1UnitID(i))}}, + {Key: "$Type", Value: typeName}, + {Key: "Name", Value: name}, + {Key: "Payload", Value: bytes.Repeat([]byte{'x'}, 8000)}, + }) + if err != nil { + t.Fatal(err) + } + return b +} + +func headerNames(t *testing.T, r *Reader, typeName string) string { + t.Helper() + hs, err := r.ListUnitHeaders(typeName) + if err != nil { + t.Fatal(err) + } + var out []string + for _, h := range hs { + out = append(out, h.ID[len(h.ID)-2:]+"="+h.Name) + } + return fmt.Sprint(out) +} + +func TestListUnitHeadersV1ReadsEachBlobOnce_Issue2094(t *testing.T) { + f, path := cachedListingFixture(t, fixtureTypes(10, 3, "Microflows$Microflow", "Forms$Page")) + + if got, want := headerNames(t, f.r, "Microflows$Microflow"), "[01=Unit1 04=Unit4 07=Unit7 10=Unit10]"; got != want { + t.Fatalf("headers: %s, want %s", got, want) + } + before := f.r.blobReads.Load() + headerNames(t, f.r, "Microflows$Microflow") + if reads := f.r.blobReads.Load() - before; reads != 0 { + t.Errorf("naming the microflows again read %d blobs; want 0", reads) + } + + // Renamed through this reader's connection, same length: the hash moves. + doc := renamed(t, 4, "Microflows$Microflow", "UnitX") + if len(doc) != len(v1UnitContents(t, 4, "Microflows$Microflow", 8000)) { + t.Fatal("the rename must keep the blob length") + } + if _, err := f.db.Exec(`UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ?`, + doc, contentsHashOf(doc), uuidToBlob(v1UnitID(4))); err != nil { + t.Fatal(err) + } + f.r.InvalidateCache() + before = f.r.blobReads.Load() + if got, want := headerNames(t, f.r, "Microflows$Microflow"), "[01=Unit1 04=UnitX 07=Unit7 10=Unit10]"; got != want { + t.Errorf("after a rename: %s, want %s", got, want) + } + if reads := f.r.blobReads.Load() - before; reads != 1 { + t.Errorf("after renaming one microflow the listing read %d blobs; want 1", reads) + } + + // Renamed by another process that left the hash alone: nothing about the + // row says it changed, so a commit from elsewhere drops every name. + doc = renamed(t, 7, "Microflows$Microflow", "UnitY") + other := otherProcess(t, path) + if _, err := other.Exec(`UPDATE Unit SET Contents = ? WHERE UnitID = ?`, doc, uuidToBlob(v1UnitID(7))); err != nil { + t.Fatal(err) + } + if got, want := headerNames(t, f.r, "Microflows$Microflow"), "[01=Unit1 04=UnitX 07=UnitY 10=Unit10]"; got != want { + t.Errorf("after another process renamed a microflow: %s, want %s", got, want) + } +} + +// A unit without a ContentsHash has nothing to tell a same-length rewrite by, +// so its name is read every time rather than remembered. +func TestListUnitHeadersV1WithoutHashRereads_Issue2094(t *testing.T) { + f := newTypedListingFixture(t, fixtureTypes(4, 2, "Microflows$Microflow", "Forms$Page")) + f.r.path = filepath.Join(t.TempDir(), "absent.mpr") + headerNames(t, f.r, "Microflows$Microflow") + doc := renamed(t, 1, "Microflows$Microflow", "UnitZ") + if _, err := f.db.Exec(`UPDATE Unit SET Contents = ? WHERE UnitID = ?`, doc, uuidToBlob(v1UnitID(1))); err != nil { + t.Fatal(err) + } + f.r.InvalidateCache() + if got, want := headerNames(t, f.r, "Microflows$Microflow"), "[01=UnitZ 03=Unit3]"; got != want { + t.Errorf("after a rename without a hash: %s, want %s", got, want) + } +} + +// The writer's v1 update is the one write path that used to leave the reader +// uninformed, when every listing rescanned the table anyway. +func TestUpdateUnitV1InvalidatesListing_Issue2094(t *testing.T) { + f, _ := cachedListingFixture(t, fixtureTypes(4, 2, "Microflows$Microflow", "Forms$Page")) + headerNames(t, f.r, "Microflows$Microflow") + if err := NewWriterWithReader(f.r).UpdateRawUnit(v1UnitID(3), renamed(t, 3, "Microflows$Microflow", "UnitW")); err != nil { + t.Fatal(err) + } + if got, want := headerNames(t, f.r, "Microflows$Microflow"), "[01=Unit1 03=UnitW]"; got != want { + t.Errorf("after the writer renamed a microflow: %s, want %s", got, want) + } +} diff --git a/modelsdk/mpr/reader_units_v1_test.go b/modelsdk/mpr/reader_units_v1_test.go index 517166f95..e9cc884f4 100644 --- a/modelsdk/mpr/reader_units_v1_test.go +++ b/modelsdk/mpr/reader_units_v1_test.go @@ -40,7 +40,8 @@ func v1UnitContents(t *testing.T, i int, typeName string, pad int) []byte { func newTypedListingFixture(t *testing.T, types []string) *typedListingFixture { t.Helper() - db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "v1.mpr")) + path := filepath.Join(t.TempDir(), "v1.mpr") + db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } @@ -50,7 +51,7 @@ func newTypedListingFixture(t *testing.T, types []string) *typedListingFixture { TreeConflict LONG, ContentsHash TEXT, ContentsConflicts TEXT, Contents BLOB)`); err != nil { t.Fatal(err) } - f := &typedListingFixture{db: db, r: &Reader{db: db, version: MPRVersionV1}} + f := &typedListingFixture{db: db, r: &Reader{db: db, version: MPRVersionV1, path: path}} for i, ty := range types { f.insert(t, i+1, ty, 8000) } @@ -129,9 +130,9 @@ func TestListUnitsByTypeV1MatchesFullScan(t *testing.T) { } } -// The type index must not go stale: v1's updateUnit writes the Unit table -// without invalidating any cache, and another process (Studio Pro) can change -// the file under a long-lived reader. +// The type index must not go stale even after a write nobody announced: the +// writes here go through the reader's own handle without InvalidateCache, and +// the kept scan of the table (ako/mxcli#2094) must still notice the file moved. func TestListUnitsByTypeV1SeesWritesWithoutInvalidate(t *testing.T) { f := newTypedListingFixture(t, fixtureTypes(10, 3, "Microflows$Microflow", "Forms$Page")) if _, err := f.r.listUnitsByType("Microflows$Microflow"); err != nil { // warm the index diff --git a/modelsdk/mpr/writer_core.go b/modelsdk/mpr/writer_core.go index 6f46d7754..2e3be883f 100644 --- a/modelsdk/mpr/writer_core.go +++ b/modelsdk/mpr/writer_core.go @@ -740,6 +740,9 @@ func (w *Writer) updateUnit(unitID string, contents []byte, opts ...canon.Option _, err = w.reader.db.Exec(` UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ? `, contents, contentsHashOf(contents), unitIDBlob) + // The reader keeps its scan of the Unit table until told it is stale + // (ako/mxcli#2094), as the v2 branch above and every other write does. + w.reader.InvalidateCache() return err } From bfa3b083e4ae45f8b151d14894ac73fa1183ae3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 20:43:43 +0000 Subject: [PATCH 13/16] fix(test): a test run holds the dev loop's watcher instead of making it rebuild and restart Field test on ako/mxcli#2104 (and ako/mxcli#2063): `test --local`, and `--attach` with a test that has to be built, write their test microflows into the project a `run --local --watch` dev loop watches. The dev loop rebuilt for the injection and its cleanup and applied by restarting the runtime, dropping every browser session (--hub preview included). The test run now writes .mxcli/watch-hold.json (pid, model digest, the dev loop's change signal) before it touches the project and removes it after cleanup; --attach now also puts the .mpr back byte for byte, as --local already did. While a live process holds it the watcher does not build; on release it builds nothing when the model is the one the hold began from and no edit was pending before it, and records the source as covered so `run status`/`run wait` agree. A dead holder's hold is ignored. `run status` shows "held by a test run". Verified on an 11.12.1 app with a watched dev loop: with #2104's binary a mixed attach run caused build #2, and a `test --local` run caused two builds whose restart failed; with this change neither built, status was clean, run wait returned at once, and an edit afterwards still built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- ...11-10-test-run-holds-dev-loop-watcher.json | 1 + CHANGELOG.md | 1 + cmd/mxcli/cmd_run_lifecycle.go | 2 + cmd/mxcli/docker/runlocal.go | 25 +++ cmd/mxcli/docker/runstate.go | 20 ++ cmd/mxcli/docker/watchhold.go | 192 ++++++++++++++++++ cmd/mxcli/docker/watchhold_test.go | 142 +++++++++++++ cmd/mxcli/testrunner/runner.go | 9 + cmd/mxcli/testrunner/runner_attach.go | 18 ++ docs-site/src/tools/running-tests.md | 8 + 10 files changed, 418 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-run-holds-dev-loop-watcher.json create mode 100644 cmd/mxcli/docker/watchhold.go create mode 100644 cmd/mxcli/docker/watchhold_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-run-holds-dev-loop-watcher.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-run-holds-dev-loop-watcher.json new file mode 100644 index 000000000..67bad9c29 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-run-holds-dev-loop-watcher.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-10", "symptom": "Field test on ako/mxcli#2104 (and ako/mxcli#2063): a `test --attach` run with a test that had to be built, or a `test --local` run, made the project's `run --local --watch` dev loop rebuild (the first build failing) and apply by restarting the runtime, dropping every browser session including a --hub preview; on the spike app the restart itself failed with EOFException. The attach's own message said the app's serve process was left alone.", "cause": "Both write the test microflows into the same .mpr the dev loop watches. The attach used a serve process of its own for its build, but the watcher saw the model change, and then the cleanup, as edits. --attach also never restored the .mpr byte for byte (only --local did), so even after cleanup the model differed.", "file": "cmd/mxcli/docker/watchhold.go (HoldWatch/ReleaseWatch, ModelDigest, watchHoldGate), cmd/mxcli/docker/runlocal.go (the watcher consults the gate), cmd/mxcli/docker/runstate.go (HeldBy, Covered), cmd/mxcli/testrunner/runner_attach.go and runner.go (hold + snapshot restore), cmd/mxcli/cmd_run_lifecycle.go (status shows the hold)", "insight": "Two processes writing one project need a protocol, not a better watcher heuristic: the writer announces itself and the watcher decides on release by content, not mtimes. The release check needs the project put back byte for byte, which test --local already did (projectSnapshot) and --attach did not. Moving the watcher's baseline is not enough on its own: the run state's Last.Source must move with it, or `run status` and `run wait` see a pending change no build will cover. The control: with the hold disabled three gate tests fail; on the spike app main's binary rebuilt twice for one test --local run (restart failed) and once for a mixed attach run, the fixed binary built nothing and an edit afterwards still built.", "refs": ["ako/mxcli#2104", "ako/mxcli#2063", "ako/mxcli#2105"], "test": "cmd/mxcli/docker/watchhold_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3401edc..af5352660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A test run no longer rebuilds or restarts the dev loop it shares a project with** (ako/mxcli#2063, field test on ako/mxcli#2104) — `mxcli test --local`, and `--attach` with a test that has to be built, write their test microflows into the project a `run --local --watch` dev loop is watching, so the dev loop rebuilt for the injection and its cleanup, and applied it by restarting the runtime, which drops every browser session, `--hub` preview included; on a small app the restart itself failed (`EOFException`). The test run now holds the dev loop's watcher (`.mxcli/watch-hold.json`) while it owns the project, `--attach` now puts the project file back byte for byte as `--local` already did, and on release the watcher compares the model with the one the hold began from: the same model is not built at all, and `run status` / `run wait` read it as covered. An edit made before or during the run is still built once it ends, and a hold whose run died is ignored. - **A dev loop hosting the test endpoint no longer reports a change it will never build, and a leftover endpoint is not committed unnoticed** (review on ako/mxcli#2101) — `run --local --watch --test-endpoint` showed "change pending" in `run status` for as long as it ran, and `run wait` waited into its timeout: the boot build scaffolds `themesource/mxtest/` for the endpoint's module, which the watcher already knew was build output (ako/mxcli#2033) but the run state's readers judged by the unfiltered signal. The scaffold is now recorded in `run-state.json`, and `run status` and `run wait` use the watcher's signal; an edit to those files still counts. A run killed with its container never removes the endpoint, so the project kept `AfterStartupMicroflow: 'MxTest.RegisterEndpoint'` until a `run stop` nobody ran. Any `mxcli run` now removes an abandoned endpoint before booting, not only the next `--test-endpoint` run, and `run` and `check -p` warn while the setting is still the endpoint's with no run hosting it, with `run stop` as the fix, or the two commands that undo it by hand when the restore record went too. - **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. - **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled. diff --git a/cmd/mxcli/cmd_run_lifecycle.go b/cmd/mxcli/cmd_run_lifecycle.go index ae7e455d3..866ef4db3 100644 --- a/cmd/mxcli/cmd_run_lifecycle.go +++ b/cmd/mxcli/cmd_run_lifecycle.go @@ -594,6 +594,8 @@ func statusLine(st *docker.RunState, p statusProbes, source, now time.Time) (str covered = st.Last.Source } switch { + case st.HeldBy != 0 && p.alive(st.HeldBy): + parts = append(parts, fmt.Sprintf("held by a test run (pid %d); a change waits for it", st.HeldBy)) case st.Building: parts = append(parts, fmt.Sprintf("building #%d", st.Gen)) case source.After(covered) && st.Watch: diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 64d8256ad..1424608be 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -1500,6 +1500,8 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, b // apply so "did my change take?" is answerable from the log without guessing. // The initial boot build is generation 1. gen := 1 + holds := &watchHoldGate{projectPath: opts.ProjectPath} + holdAnnounced := false ticker := time.NewTicker(opts.PollInterval) defer ticker.Stop() @@ -1517,6 +1519,29 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, b case <-ticker.C: source := func() time.Time { return sourceMTimeExcept(opts.ProjectPath, scaffold) } now := source() + // Another mxcli process (a `test --attach`) may be changing the + // project and putting it back: wait for it, then build only if the + // model did not come back as it was (watchhold.go). + switch verdict, hold := holds.tick(last); verdict { + case holdWait: + if !holdAnnounced { + fmt.Fprintf(w, " a test run (pid %d) is changing the project; not rebuilding until it is done\n", hold.PID) + opts.State.Held(hold.PID) + holdAnnounced = true + } + continue + case holdAbsorbed: + fmt.Fprintf(w, " the test run (pid %d) is done and the model is as it was before it; nothing to rebuild\n", hold.PID) + opts.State.Held(0) + opts.State.Covered(now) + holdAnnounced = false + last = now + continue + case holdReleased: + fmt.Fprintf(w, " the test run (pid %d) is done; the model changed meanwhile, so rebuilding\n", hold.PID) + opts.State.Held(0) + holdAnnounced = false + } if !now.After(last) { continue } diff --git a/cmd/mxcli/docker/runstate.go b/cmd/mxcli/docker/runstate.go index 8f691e86d..d6f1aa2a0 100644 --- a/cmd/mxcli/docker/runstate.go +++ b/cmd/mxcli/docker/runstate.go @@ -122,6 +122,9 @@ type RunState struct { // (`run status`, `run wait`) judges "change pending" by the signal the // watcher uses — SourceMTimeOf. Scaffold map[string]time.Time `json:"scaffold,omitempty"` + // HeldBy is the process holding the watcher (a `test --attach` changing the + // project for its run, watchhold.go); 0 when nothing does. + HeldBy int `json:"heldBy,omitempty"` // Last is the newest finished generation. Last *BuildOutcome `json:"last,omitempty"` Updated time.Time `json:"updated"` @@ -271,6 +274,23 @@ func (r *RunStateRecorder) Scaffold(s buildScaffold) { r.update(func(st *RunState) { st.Scaffold = cp }) } +// Covered records that the latest build covers source up to t without a new +// build: the watcher found the model it already serves (a hold released over an +// unchanged model). Without it `run status` read the project's newer mtimes as a +// change pending, and `run wait` waited for a build that was never coming. +func (r *RunStateRecorder) Covered(t time.Time) { + r.update(func(st *RunState) { + if st.Last != nil && t.After(st.Last.Source) { + st.Last.Source = t + } + }) +} + +// Held records which process holds the watcher, or 0 when the hold ended. +func (r *RunStateRecorder) Held(pid int) { + r.update(func(st *RunState) { st.HeldBy = pid }) +} + // BuildStarted records that generation gen is being built from source. func (r *RunStateRecorder) BuildStarted(gen int, source time.Time) { r.update(func(s *RunState) { diff --git a/cmd/mxcli/docker/watchhold.go b/cmd/mxcli/docker/watchhold.go new file mode 100644 index 000000000..3185981ab --- /dev/null +++ b/cmd/mxcli/docker/watchhold.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "time" +) + +// A watch hold is how another mxcli process tells a `run --local --watch` dev +// loop that it is changing the project for a while and will put it back. +// +// `mxcli test --attach` injects its test microflows into the same project the +// dev loop watches, builds them through a serve process of its own, runs the +// tests, and takes them out again. The watcher saw each of those writes as an +// edit: it rebuilt (the first build failing on a half-written model) and +// applied the injected model by restarting the runtime, which drops every +// browser session, including a --hub preview's (field test on ako/mxcli#2104, +// the same disruption as ako/mxcli#2063). So the attach holds the watcher while +// it owns the project, and when it lets go the watcher compares the model with +// the one the hold started from: unchanged means there is nothing to build. +// +// A hold whose owner is gone counts as released, so a crashed test run cannot +// freeze the dev loop. + +const watchHoldName = "watch-hold.json" + +// WatchHold is the content of the hold file. +type WatchHold struct { + // PID is the process holding the watcher; the hold lasts while it lives. + PID int `json:"pid"` + // Digest is ModelDigest of the project when the hold began, before the + // holder changed anything. + Digest string `json:"digest"` + // Source is the dev loop's change signal (SourceMTimeOf) when the hold + // began. An edit the watcher had not built yet makes it newer than the + // watcher's baseline, and then the release builds even if the digest matches. + Source time.Time `json:"source"` + Started time.Time `json:"started"` +} + +func watchHoldPath(projectPath string) string { + return filepath.Join(filepath.Dir(projectPath), ".mxcli", watchHoldName) +} + +// HoldWatch writes a hold for pid on the project as it is now. Call it before +// changing anything; ReleaseWatch ends it. +func HoldWatch(projectPath string, pid int) error { + digest, err := ModelDigest(projectPath) + if err != nil { + return err + } + st, _ := ReadRunState(projectPath) + h := WatchHold{PID: pid, Digest: digest, Source: SourceMTimeOf(projectPath, st), Started: time.Now()} + body, err := json.Marshal(h) + if err != nil { + return err + } + path := watchHoldPath(projectPath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, body, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// ReleaseWatch ends a hold. Releasing a project that has none is not an error. +func ReleaseWatch(projectPath string) { os.Remove(watchHoldPath(projectPath)) } + +// readWatchHold returns the hold on the project, or nil when there is none. +func readWatchHold(projectPath string) *WatchHold { + body, err := os.ReadFile(watchHoldPath(projectPath)) + if err != nil { + return nil + } + var h WatchHold + if json.Unmarshal(body, &h) != nil { + return nil + } + return &h +} + +// holdAlive is PidAlive, a seam for the tests. +var holdAlive = PidAlive + +// activeWatchHold returns the hold on the project when its owner is alive. +func activeWatchHold(projectPath string) *WatchHold { + h := readWatchHold(projectPath) + if h == nil || !holdAlive(h.PID) { + return nil + } + return h +} + +// ModelDigest hashes the project's model as stored: the .mpr file and, for MPR +// v2, every file under mprcontents/ (path and content). Two equal digests are +// the same model byte for byte, which is what lets the watcher skip a build +// after a hold: a test run puts the .mpr back exactly (projectSnapshot), so a +// model that went out and came back reads as unchanged. +func ModelDigest(projectPath string) (string, error) { + h := sha256.New() + if err := hashFile(h, projectPath); err != nil { + return "", err + } + dir := filepath.Join(filepath.Dir(projectPath), "mprcontents") + var paths []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) && p == dir { + return filepath.SkipDir + } + return err + } + if !d.IsDir() { + paths = append(paths, p) + } + return nil + }) + if err != nil { + return "", err + } + sort.Strings(paths) + for _, p := range paths { + rel, _ := filepath.Rel(dir, p) + io.WriteString(h, filepath.ToSlash(rel)+"\x00") + if err := hashFile(h, p); err != nil { + return "", err + } + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func hashFile(w io.Writer, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err +} + +// watchHoldGate is the watcher's side of a hold: it remembers the hold it saw, +// and decides on release whether the change it left behind needs a build. +type watchHoldGate struct { + projectPath string + held *WatchHold +} + +// holdVerdict is what the watcher does on a tick. +type holdVerdict int + +const ( + holdNone holdVerdict = iota // no hold, or one already settled: carry on as usual + holdWait // a live hold: do not build + holdAbsorbed // released, and the model is as before: move the baseline, do not build + holdReleased // released, and something changed: build as usual +) + +// tick reports what the watcher should do now. last is the watcher's baseline, +// the source time its latest build covered. +func (g *watchHoldGate) tick(last time.Time) (holdVerdict, *WatchHold) { + if h := activeWatchHold(g.projectPath); h != nil { + if g.held == nil { + g.held = h + } + return holdWait, g.held + } + if g.held == nil { + return holdNone, nil + } + h := g.held + g.held = nil + // An edit made before the hold and not yet built must still be built. + if h.Source.After(last) { + return holdReleased, h + } + if d, err := ModelDigest(g.projectPath); err == nil && d == h.Digest { + return holdAbsorbed, h + } + return holdReleased, h +} diff --git a/cmd/mxcli/docker/watchhold_test.go b/cmd/mxcli/docker/watchhold_test.go new file mode 100644 index 000000000..1aad333c0 --- /dev/null +++ b/cmd/mxcli/docker/watchhold_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// heldProject is a v2-shaped project with a hold on it, owned by a process +// whose liveness the test sets. +func heldProject(t *testing.T) (mpr string, alive *bool) { + t.Helper() + dir := t.TempDir() + mpr = filepath.Join(dir, "App.mpr") + touch(t, mpr, time.Now().Add(-time.Hour)) + touch(t, filepath.Join(dir, "mprcontents", "ab", "cd", "unit.mxunit"), time.Now().Add(-time.Hour)) + live := true + old := holdAlive + holdAlive = func(int) bool { return live } + t.Cleanup(func() { holdAlive = old }) + if err := HoldWatch(mpr, 4242); err != nil { + t.Fatal(err) + } + return mpr, &live +} + +// field test on ako/mxcli#2104: a `test --attach` that injected test +// microflows made the dev loop rebuild and restart the app. While the attach +// holds the project the watcher waits; when it lets go and the model is back as +// it was, there is nothing to build. +func TestWatchHoldGate_WaitsThenAbsorbsAModelPutBack(t *testing.T) { + mpr, _ := heldProject(t) + g := &watchHoldGate{projectPath: mpr} + last := time.Now() + + // The attach injects: a new document appears while the hold is live. + injected := filepath.Join(filepath.Dir(mpr), "mprcontents", "ef", "01", "test.mxunit") + touch(t, injected, time.Now()) + if v, h := g.tick(last); v != holdWait || h == nil || h.PID != 4242 { + t.Fatalf("live hold: verdict %v, hold %+v; want holdWait for pid 4242", v, h) + } + + // It takes the injection out and puts the project back, then releases. + os.Remove(injected) + ReleaseWatch(mpr) + if v, _ := g.tick(last); v != holdAbsorbed { + t.Fatalf("released over an unchanged model: verdict %v, want holdAbsorbed", v) + } + if v, _ := g.tick(last); v != holdNone { + t.Errorf("after the release was handled: verdict %v, want holdNone", v) + } +} + +// A model that did not come back as it was (the developer edited it meanwhile) +// is built as usual. +func TestWatchHoldGate_BuildsAModelThatChanged(t *testing.T) { + mpr, _ := heldProject(t) + g := &watchHoldGate{projectPath: mpr} + last := time.Now() + if v, _ := g.tick(last); v != holdWait { + t.Fatalf("verdict %v, want holdWait", v) + } + touch(t, filepath.Join(filepath.Dir(mpr), "mprcontents", "ab", "cd", "edited.mxunit"), time.Now()) + ReleaseWatch(mpr) + if v, _ := g.tick(last); v != holdReleased { + t.Errorf("released over a changed model: verdict %v, want holdReleased", v) + } +} + +// An edit made before the hold that the watcher had not built yet is still +// built, though the model at release equals the model at the hold. +func TestWatchHoldGate_BuildsAnEditPendingBeforeTheHold(t *testing.T) { + mpr, _ := heldProject(t) + g := &watchHoldGate{projectPath: mpr} + before := time.Now().Add(-2 * time.Hour) // the watcher's last build predates the project's edits + if v, _ := g.tick(before); v != holdWait { + t.Fatalf("verdict %v, want holdWait", v) + } + ReleaseWatch(mpr) + if v, _ := g.tick(before); v != holdReleased { + t.Errorf("a change pending before the hold was absorbed: verdict %v, want holdReleased", v) + } +} + +// A hold whose owner died is no hold: a crashed test run cannot freeze the loop. +func TestWatchHoldGate_IgnoresADeadOwner(t *testing.T) { + mpr, alive := heldProject(t) + *alive = false + g := &watchHoldGate{projectPath: mpr} + if v, _ := g.tick(time.Now()); v != holdNone { + t.Errorf("a dead owner's hold: verdict %v, want holdNone", v) + } +} + +// The digest is the stored model: equal for the same bytes, different for a +// changed .mpr or document. +func TestModelDigest(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + touch(t, mpr, time.Now()) + unit := filepath.Join(dir, "mprcontents", "u.mxunit") + touch(t, unit, time.Now()) + a, err := ModelDigest(mpr) + if err != nil { + t.Fatal(err) + } + if b, _ := ModelDigest(mpr); b != a { + t.Error("the same model digests differently") + } + os.WriteFile(unit, []byte("changed"), 0o644) + if c, _ := ModelDigest(mpr); c == a { + t.Error("a changed document did not change the digest") + } + v1 := filepath.Join(t.TempDir(), "Old.mpr") + touch(t, v1, time.Now()) + if _, err := ModelDigest(v1); err != nil { + t.Errorf("an MPR v1 project (no mprcontents/) failed: %v", err) + } +} + +// After an absorbed hold the run state says the build covers the project as it +// is now, so `run status` shows no change pending and `run wait` returns. +func TestRunStateCovered(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + touch(t, mpr, time.Now()) + rec := NewRunStateRecorder(mpr, os.Getpid(), false, true, nil, "") + boot := time.Now().Add(-time.Minute) + rec.BuildFinished(BuildOutcome{Gen: 1, OK: true, Action: "boot", Source: boot}) + later := time.Now() + rec.Covered(later) + if st := rec.State(); st.Last == nil || !st.Last.Source.Equal(later) { + t.Errorf("Last.Source = %v, want %v", st.Last, later) + } + rec.Covered(boot) // never moves back + if st := rec.State(); !st.Last.Source.Equal(later) { + t.Errorf("Covered moved the source back to %v", st.Last.Source) + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index fda288ce7..c8703eab5 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -299,6 +299,15 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. return nil, fmt.Errorf("capturing project state: %w", err) } + // A `run --local --watch` dev loop on this project would rebuild — and may + // restart its app — for the endpoint and test microflows this run injects + // (ako/mxcli#2063). Held, it waits, and on release finds the model as it + // was: the cleanup puts the project file back byte for byte. Released when + // this function returns, after that cleanup. + if err := docker.HoldWatch(opts.ProjectPath, os.Getpid()); err == nil { + defer docker.ReleaseWatch(opts.ProjectPath) + } + fmt.Fprintln(w, "Generating test endpoint and test microflows...") chain := state.afterStartup if opts.SkipAppStartup { diff --git a/cmd/mxcli/testrunner/runner_attach.go b/cmd/mxcli/testrunner/runner_attach.go index a2279b563..1555e967d 100644 --- a/cmd/mxcli/testrunner/runner_attach.go +++ b/cmd/mxcli/testrunner/runner_attach.go @@ -141,9 +141,15 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. // Only the generated test microflows are ours to remove. injected := suite + // snapshot is the project file before the injection, put back after it so + // the dev loop sees the model it had (see holdDevLoop). + var snapshot projectSnapshot finish := func(result *SuiteResult, runErr error) (*SuiteResult, error) { fmt.Fprintln(w, "Cleaning up...") cleanupErr := runMDLCommands(opts.ProjectPath, dropTestFlows(opts.ProjectPath, injected)) + if err := snapshot.restore(cleanupErr == nil); err != nil { + fmt.Fprintf(w, " note: %v\n", err) + } if cleanupErr == nil { // Leave the app serving a model that matches the project on disk; // otherwise the developer's next page load still runs the test flows. @@ -204,6 +210,18 @@ func runAttached(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. injected = toInject } + // The dev loop watches the project this run is about to write to. Held, it + // does not rebuild (and restart the app) for the injection, and on release + // it finds the model as it was. Released when this function returns, after + // finish has taken the injection out and put the project file back. + snapshot = takeProjectSnapshot(opts.ProjectPath) + if err := docker.HoldWatch(opts.ProjectPath, os.Getpid()); err != nil { + fmt.Fprintf(w, " note: could not hold the dev loop's watcher (%v); it may rebuild for the test microflows\n", err) + } else { + defer docker.ReleaseWatch(opts.ProjectPath) + fmt.Fprintln(w, " the dev loop's watcher is held for this run; the app is not rebuilt for the test microflows") + } + fmt.Fprintln(w, "Injecting test microflows...") if err := execMDLScript(opts.ProjectPath, GenerateTestFlows(toInject), "mxtest-flows-*.mdl"); err != nil { return finish(nil, fmt.Errorf("injecting test microflows: %w", err)) diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index 324db0212..bcf8c6030 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -177,6 +177,14 @@ mxcli test tests/ -p app.mpr --local --mxbuild-path "C:\Program Files\Mendix\11. `--watch` is the everyday loop: edit a test *or* the microflow under test, and the verdict lands in about two seconds. +A test run that writes its test microflows into a project a `run --local --watch` +dev loop is watching (`--local`, or `--attach` with a test that has to be built) +holds that dev loop's watcher while it owns the project: the dev loop does not +rebuild, or restart its app, for the injection, `run status` shows +`held by a test run (pid …)`, and when the run has put the project back exactly +the watcher finds nothing to build. An edit made in the meantime is built once +the run ends. A hold whose run died is ignored. + `--attach` skips even the first boot, at one cost worth knowing: the tests run against the running app's database rather than a scratch one, so they can leave data behind in the app you are looking at. It needs the dev loop to have been From d8baff7b77578b60b2d1a6f8995f7fedde9c4741 Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 21:07:12 +0000 Subject: [PATCH 14/16] perf(executor): resolve a flow's entity types per module, find a microflow by name (#2094) Building a microflow resolved each entity-typed parameter and return type by decoding every domain model in the project (once per lookup; nanoflows and rules once per build), and create or modify microflow found the stored flow by decoding every microflow. Both now read only what they need: the domain model of the module a type names, lazily and once per build, and the microflows of that local name. Co-Authored-By: Claude Opus 5.5 --- ...flow-build-decodes-every-domain-model.json | 1 + CHANGELOG.md | 1 + .../bug-tests/2094-flow-build-lookups.mdl | 52 ++++ mdl/executor/calculated_attributes.go | 8 +- mdl/executor/cmd_microflows_build.go | 79 ++---- mdl/executor/cmd_rules_create.go | 43 +--- mdl/executor/entity_resolver.go | 105 ++++++++ mdl/executor/flow_build_decodes_2094_test.go | 228 ++++++++++++++++++ 8 files changed, 428 insertions(+), 89 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-10-flow-build-decodes-every-domain-model.json create mode 100644 mdl-examples/bug-tests/2094-flow-build-lookups.mdl create mode 100644 mdl/executor/entity_resolver.go create mode 100644 mdl/executor/flow_build_decodes_2094_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-flow-build-decodes-every-domain-model.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-flow-build-decodes-every-domain-model.json new file mode 100644 index 000000000..50ca260b3 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-10-flow-build-decodes-every-domain-model.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-10", "symptom": "ako/mxcli#2094 (mendixlabs/mxcli#1795), follow-up to #2115: after the v1 listing fix, a one-activity `create or modify microflow` still cost 0.35 s per statement on a 3,400-unit project (and 0.06 s on a 1,054-unit v2 one); the profile was ListDomainModels (32%) and ListMicroflows (22%), on v1 and v2 alike.", "cause": "buildMicroflowFromStmt's entityResolver decoded every domain model of the project on EACH entity lookup (6 full decodes for a flow with two entity parameters and an entity return); the nanoflow and rule builders decoded them all once per build; findMicroflowByQualifiedName decoded every microflow to pick one by qualified name, and `create or modify microflow` calls it per statement.", "file": "mdl/executor/entity_resolver.go (flowEntityResolver: lazy, GetDomainModel per named module, full listing only as fallback), cmd_microflows_build.go + cmd_rules_create.go (use it; a backend failure is reported, not read as 'entity not found'), calculated_attributes.go (findMicroflowByQualifiedName via microflowsNamed)", "insight": "Peek the profile's CALLERS of the expensive listing before designing a cache: 73% of ListDomainModels came from one closure and 100% of ListMicroflows from one helper, so two local fixes did what a cross-statement backend memo would, without its invalidation risk (64 and 51 call sites, and the verdict pass applies simulated flow writes, so a pass-wide microflow memo would serve stale flows). Keep the lookup LAZY: listing the modules up front made every build read every module unit on v2 even for flows with no entity type, which TestCheckPasses_ReadEachUnitFileOnce caught (20 reads vs 12). Count backend calls with a wrapper that embeds MockBackend and adds the by-name/by-module methods the mock lacks; the mock's GetDomainModel returns (nil, nil), so the resolver must fall back to the full listing for the other 80-odd mock tests. Measured, OneHarness v1: create or modify 0.86 -> 0.58 s alone, 0.115 s with #2115; check of 150 statements 76 -> 50 s alone, 10 s with #2115; v2 TestApp 0.061 -> 0.020 s.", "refs": ["ako/mxcli#2094", "mendixlabs/mxcli#1795", "ako/mxcli#2115", "mendixlabs/mxcli#1272"], "test": "mdl/executor/flow_build_decodes_2094_test.go, mdl-examples/bug-tests/2094-flow-build-lookups.mdl"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7cf9f7f..0e41c8617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Building a flow no longer decodes the whole project to resolve one entity or find one microflow** (ako/mxcli#2094, mendixlabs/mxcli#1795) — each entity-typed parameter and return type of a microflow was resolved by decoding every domain model in the project, and `create or modify microflow` found the stored flow by decoding every microflow, so `exec` and `check` paid a fixed cost per statement that grew with the project. A flow now reads only the domain model of the module a type names, and the microflows with that name. On a 3,400-unit project a one-activity `create or modify` went from 0.86 s to 0.58 s (0.12 s together with the MPR v1 listing fix), and on a 1,054-unit v2 project from 0.061 s to 0.020 s. - **A dev loop hosting the test endpoint no longer reports a change it will never build, and a leftover endpoint is not committed unnoticed** (review on ako/mxcli#2101) — `run --local --watch --test-endpoint` showed "change pending" in `run status` for as long as it ran, and `run wait` waited into its timeout: the boot build scaffolds `themesource/mxtest/` for the endpoint's module, which the watcher already knew was build output (ako/mxcli#2033) but the run state's readers judged by the unfiltered signal. The scaffold is now recorded in `run-state.json`, and `run status` and `run wait` use the watcher's signal; an edit to those files still counts. A run killed with its container never removes the endpoint, so the project kept `AfterStartupMicroflow: 'MxTest.RegisterEndpoint'` until a `run stop` nobody ran. Any `mxcli run` now removes an abandoned endpoint before booting, not only the next `--test-endpoint` run, and `run` and `check -p` warn while the setting is still the endpoint's with no run hosting it, with `run stop` as the fix, or the two commands that undo it by hand when the restore record went too. - **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. - **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled. diff --git a/mdl-examples/bug-tests/2094-flow-build-lookups.mdl b/mdl-examples/bug-tests/2094-flow-build-lookups.mdl new file mode 100644 index 000000000..2a445c51b --- /dev/null +++ b/mdl-examples/bug-tests/2094-flow-build-lookups.mdl @@ -0,0 +1,52 @@ +mdl 1; +-- ako/mxcli#2094 (mendixlabs/mxcli#1795): building a flow resolved each +-- entity-typed parameter and return type by decoding every domain model in the +-- project, and `create or modify microflow` found the stored flow by decoding +-- every microflow. Both now read only what they need: the domain model of the +-- module a type names, and the microflows of that local name. +-- +-- This script checks the narrower lookups still find the right thing: entity +-- types from two modules in a microflow, a nanoflow and a rule, and a +-- `create or modify` of a microflow whose name another module also uses. +-- +-- Run on a fresh app: +-- mxcli exec mdl-examples/bug-tests/2094-flow-build-lookups.mdl -p App.mpr +-- Expected: every statement succeeds; the last one reports +-- "Modified microflow: Bug2094A.Pick", and +-- mxcli -p App.mpr -c "describe microflow Bug2094B.Pick" +-- still shows the one-statement body (`return $o;`). + +create module Bug2094A; +create module Bug2094B; + +create persistent entity Bug2094A.Customer (Name: String(100)); +create persistent entity Bug2094B.Order (Number: Integer); + +create microflow Bug2094A.Pick ($c: Bug2094A.Customer, $orders: list of Bug2094B.Order) returns Bug2094B.Order +begin + $o = head $orders; + return $o; +end; + +create microflow Bug2094B.Pick ($o: Bug2094B.Order) returns Bug2094B.Order +begin + return $o; +end; + +create nanoflow Bug2094A.NF_Pick ($c: Bug2094A.Customer, $o: Bug2094B.Order) returns Bug2094B.Order +begin + return $o; +end; + +create rule Bug2094A.RL_Has ($c: Bug2094A.Customer, $o: Bug2094B.Order) returns boolean +begin + return $o != empty; +end; + +-- Finds Bug2094A.Pick, not its namesake in Bug2094B. +create or modify microflow Bug2094A.Pick ($c: Bug2094A.Customer, $orders: list of Bug2094B.Order) returns Bug2094B.Order +begin + $o = head $orders; + log info node 'Bug2094' 'picked'; + return $o; +end; diff --git a/mdl/executor/calculated_attributes.go b/mdl/executor/calculated_attributes.go index 6437ec9fa..ecefb0117 100644 --- a/mdl/executor/calculated_attributes.go +++ b/mdl/executor/calculated_attributes.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" @@ -122,7 +123,12 @@ func calculatedReturnTypeName(t ast.DataType) string { // findMicroflowByQualifiedName returns the live microflow with this qualified // name, or nil. Excluded twins are skipped (#914). func findMicroflowByQualifiedName(ctx *ExecContext, qualifiedName string) *microflows.Microflow { - all, err := ctx.Backend.ListMicroflows() + // Only the microflows of that local name are decoded: listing all of them + // to pick one cost a decode of every microflow in the project per call, + // and `create or modify microflow` makes one per statement + // (ako/mxcli#2094). + localName := qualifiedName[strings.LastIndex(qualifiedName, ".")+1:] + all, err := microflowsNamed(ctx, localName) if err != nil { return nil } diff --git a/mdl/executor/cmd_microflows_build.go b/mdl/executor/cmd_microflows_build.go index a09e772f9..dac285d0c 100644 --- a/mdl/executor/cmd_microflows_build.go +++ b/mdl/executor/cmd_microflows_build.go @@ -306,32 +306,10 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b return nil, err } - // Build entity resolver function for parameter/return types - entityResolver := func(qn ast.QualifiedName) model.ID { - // Get all domain models and build module name map - dms, err := ctx.Backend.ListDomainModels() - if err != nil { - return "" - } - modules, _ := ctx.Backend.ListModules() - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - // Search for entity in all domain models - for _, dm := range dms { - modName := moduleNames[dm.ContainerID] - if modName != qn.Module { - continue - } - for _, ent := range dm.Entities { - if ent.Name == qn.Name { - return ent.ID - } - } - } - return "" - } + // Build entity resolver function for parameter/return types: it decodes + // only the domain model of the module a type names (ako/mxcli#2094). + resolver := newFlowEntityResolver(ctx) + entityResolver := resolver.resolve // Validate and add parameters for i, p := range s.Parameters { @@ -343,6 +321,9 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b if p.Type.EntityRef != nil && !isBuiltinModuleEntity(p.Type.EntityRef.Module) { entityID := entityResolver(*p.Type.EntityRef) if entityID == "" { + if resolver.err != nil { + return nil, mdlerrors.NewBackend("read domain models", resolver.err) + } // Bare qualified name in microflow context is treated as TypeEntity by the // visitor, but it may actually be an enumeration. Try enum lookup before failing. if retyped, ok := bareEnumerationType(ctx, p.Type); ok { @@ -382,6 +363,9 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { + if resolver.err != nil { + return nil, mdlerrors.NewBackend("read domain models", resolver.err) + } // A bare qualified name may be an enumeration, as for parameters above. if retyped, ok := bareEnumerationType(ctx, s.ReturnType.Type); ok && s.ReturnType.Type.Kind == ast.TypeEntity { s.ReturnType.Type = retyped @@ -640,37 +624,12 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui nf.AllowedModuleRoles = existingAllowedRoles } - // Load metadata needed by the entity resolver up front so backend read - // failures are returned as actionable errors instead of being treated as - // "entity not found". - dms, err := ctx.Backend.ListDomainModels() - if err != nil { - return nil, mdlerrors.NewBackend("list domain models", err) - } - modules, err := ctx.Backend.ListModules() - if err != nil { - return nil, mdlerrors.NewBackend("list modules", err) - } - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - // Build entity resolver function for parameter/return types - entityResolver := func(qn ast.QualifiedName) model.ID { - for _, dm := range dms { - modName := moduleNames[dm.ContainerID] - if modName != qn.Module { - continue - } - for _, ent := range dm.Entities { - if ent.Name == qn.Name { - return ent.ID - } - } - } - return "" - } + // The entity resolver decodes only the domain model of the module a type + // names, on the first lookup (ako/mxcli#2094). A backend failure is + // returned where the entity is looked up, as an actionable error rather + // than as "entity not found". + resolver := newFlowEntityResolver(ctx) + entityResolver := resolver.resolve // Validate and add parameters for i, p := range s.Parameters { @@ -679,6 +638,9 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui if p.Type.EntityRef != nil && !isBuiltinModuleEntity(p.Type.EntityRef.Module) { entityID := entityResolver(*p.Type.EntityRef) if entityID == "" { + if resolver.err != nil { + return nil, mdlerrors.NewBackend("read domain models", resolver.err) + } // Bare qualified name in microflow context is treated as TypeEntity by the // visitor, but it may actually be an enumeration. Try enum lookup before failing. if retyped, ok := bareEnumerationType(ctx, p.Type); ok { @@ -714,6 +676,9 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { + if resolver.err != nil { + return nil, mdlerrors.NewBackend("read domain models", resolver.err) + } // A bare qualified name may be an enumeration, as for parameters above. if retyped, ok := bareEnumerationType(ctx, s.ReturnType.Type); ok && s.ReturnType.Type.Kind == ast.TypeEntity { s.ReturnType.Type = retyped diff --git a/mdl/executor/cmd_rules_create.go b/mdl/executor/cmd_rules_create.go index 30c20d41e..9507a3258 100644 --- a/mdl/executor/cmd_rules_create.go +++ b/mdl/executor/cmd_rules_create.go @@ -129,37 +129,12 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { rule.Documentation = carriedDocumentation(s.DocumentationSet, s.Documentation, existingDocumentation) } - // Load metadata needed by the entity resolver up front so backend read - // failures are returned as actionable errors instead of being treated as - // "entity not found". - dms, err := ctx.Backend.ListDomainModels() - if err != nil { - return mdlerrors.NewBackend("list domain models", err) - } - modules, err := ctx.Backend.ListModules() - if err != nil { - return mdlerrors.NewBackend("list modules", err) - } - moduleNames := make(map[model.ID]string) - for _, m := range modules { - moduleNames[m.ID] = m.Name - } - - // Build entity resolver function for parameter/return types - entityResolver := func(qn ast.QualifiedName) model.ID { - for _, dm := range dms { - modName := moduleNames[dm.ContainerID] - if modName != qn.Module { - continue - } - for _, ent := range dm.Entities { - if ent.Name == qn.Name { - return ent.ID - } - } - } - return "" - } + // The entity resolver decodes only the domain model of the module a type + // names, on the first lookup (ako/mxcli#2094). A backend failure is + // returned where the entity is looked up, as an actionable error rather + // than as "entity not found". + resolver := newFlowEntityResolver(ctx) + entityResolver := resolver.resolve // Validate and add parameters for i, p := range s.Parameters { @@ -168,6 +143,9 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { if p.Type.EntityRef != nil && !isBuiltinModuleEntity(p.Type.EntityRef.Module) { entityID := entityResolver(*p.Type.EntityRef) if entityID == "" { + if resolver.err != nil { + return mdlerrors.NewBackend("read domain models", resolver.err) + } // Bare qualified name in microflow context is treated as TypeEntity by the // visitor, but it may actually be an enumeration. Try enum lookup before failing. if retyped, ok := bareEnumerationType(ctx, p.Type); ok { @@ -203,6 +181,9 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { + if resolver.err != nil { + return mdlerrors.NewBackend("read domain models", resolver.err) + } // A bare qualified name may be an enumeration, as for parameters above. if retyped, ok := bareEnumerationType(ctx, s.ReturnType.Type); ok && s.ReturnType.Type.Kind == ast.TypeEntity { s.ReturnType.Type = retyped diff --git a/mdl/executor/entity_resolver.go b/mdl/executor/entity_resolver.go new file mode 100644 index 000000000..73866767f --- /dev/null +++ b/mdl/executor/entity_resolver.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// flowEntityResolver finds an entity's ID by qualified name for a flow's +// parameter and return types. It decodes only the domain model of the module +// named, each at most once: the builders used to decode every domain model in +// the project — a microflow once per lookup — which on a large project was a +// third of the cost of a one-activity `create or modify` (ako/mxcli#2094). +// +// It lives for one build, so it can never serve a domain model older than the +// statement that asked. +type flowEntityResolver struct { + ctx *ExecContext + // moduleIDs is listed on the first lookup: a flow with no entity-typed + // parameter or return type needs neither the modules nor a domain model. + moduleIDs map[string]model.ID + byModule map[model.ID]*domainmodel.DomainModel + // all is every domain model, loaded only when a module's own could not be + // read on its own (a backend without GetDomainModel answers nil). + all []*domainmodel.DomainModel + loaded bool + // err is the first backend failure, so a builder reports it rather than + // call the entity missing. + err error +} + +func newFlowEntityResolver(ctx *ExecContext) *flowEntityResolver { + return &flowEntityResolver{ctx: ctx, byModule: map[model.ID]*domainmodel.DomainModel{}} +} + +// failed records the first backend failure. +func (r *flowEntityResolver) failed(err error) { + if r.err == nil { + r.err = err + } +} + +// domainModel is the domain model of the module with this ID, read once. +func (r *flowEntityResolver) domainModel(moduleID model.ID) *domainmodel.DomainModel { + if dm, ok := r.byModule[moduleID]; ok { + return dm + } + dm, err := r.ctx.Backend.GetDomainModel(moduleID) + if err != nil || dm == nil { + dm = r.fromAll(moduleID) + } + r.byModule[moduleID] = dm + return dm +} + +// fromAll is the module's domain model out of the full listing, the builders' +// former path, kept for a backend that cannot read one module's. +func (r *flowEntityResolver) fromAll(moduleID model.ID) *domainmodel.DomainModel { + if !r.loaded { + var err error + r.all, err = r.ctx.Backend.ListDomainModels() + r.loaded = true + if err != nil { + r.failed(err) + } + } + for _, dm := range r.all { + if dm.ContainerID == moduleID { + return dm + } + } + return nil +} + +// resolve is the entity's ID, or "" when the module or the entity is not +// there (or the backend failed: see err). +func (r *flowEntityResolver) resolve(qn ast.QualifiedName) model.ID { + if r.moduleIDs == nil { + modules, err := r.ctx.Backend.ListModules() + if err != nil { + r.failed(err) + return "" + } + r.moduleIDs = make(map[string]model.ID, len(modules)) + for _, m := range modules { + r.moduleIDs[m.Name] = m.ID + } + } + moduleID, ok := r.moduleIDs[qn.Module] + if !ok { + return "" + } + dm := r.domainModel(moduleID) + if dm == nil { + return "" + } + for _, ent := range dm.Entities { + if ent.Name == qn.Name { + return ent.ID + } + } + return "" +} diff --git a/mdl/executor/flow_build_decodes_2094_test.go b/mdl/executor/flow_build_decodes_2094_test.go new file mode 100644 index 000000000..96c8b6567 --- /dev/null +++ b/mdl/executor/flow_build_decodes_2094_test.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// ako/mxcli#2094 (mendixlabs/mxcli#1795): exec paid a fixed cost per +// statement that grew with the project, not with the change. Building a flow +// resolved each entity-typed parameter and return type by decoding EVERY +// domain model in the project (a microflow did it again for each lookup), and +// `create or modify microflow` found the stored flow by decoding EVERY +// microflow. On a 3,400-unit project the two were 54% of the profile of a +// one-activity `create or modify`. + +// decodeCountingBackend counts the listings that decode a whole document type, +// and serves the by-name and by-module lookups the real backend has. +type decodeCountingBackend struct { + *mock.MockBackend + listDomainModels int + getDomainModel map[model.ID]int + listMicroflows int + microflowsNamed int + dms []*domainmodel.DomainModel + mfs []*microflows.Microflow +} + +func (b *decodeCountingBackend) ListDomainModels() ([]*domainmodel.DomainModel, error) { + b.listDomainModels++ + return b.dms, nil +} + +func (b *decodeCountingBackend) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, error) { + b.getDomainModel[moduleID]++ + for _, dm := range b.dms { + if dm.ContainerID == moduleID { + return dm, nil + } + } + return nil, fmt.Errorf("domain model not found for module: %s", moduleID) +} + +func (b *decodeCountingBackend) ListMicroflows() ([]*microflows.Microflow, error) { + b.listMicroflows++ + return b.mfs, nil +} + +func (b *decodeCountingBackend) ListMicroflowsNamed(name string) ([]*microflows.Microflow, error) { + b.microflowsNamed++ + var out []*microflows.Microflow + for _, mf := range b.mfs { + if mf.Name == name { + out = append(out, mf) + } + } + return out, nil +} + +func (b *decodeCountingBackend) ListMicroflowHeaders() ([]types.DocumentHeader, error) { + var out []types.DocumentHeader + for _, mf := range b.mfs { + out = append(out, types.DocumentHeader{ID: mf.ID, ContainerID: mf.ContainerID, Name: mf.Name, Excluded: mf.Excluded}) + } + return out, nil +} + +// decodeCountingCtx is a project of six modules, each with a domain model, +// Shop holding Customer, and the stored microflows given. +func decodeCountingCtx(t *testing.T, stored func(shop, other *model.Module) []*microflows.Microflow) (*ExecContext, *decodeCountingBackend) { + t.Helper() + shop := mkModule("Shop") + mods := []*model.Module{shop} + dms := []*domainmodel.DomainModel{mkDomainModel(shop.ID, mkEntity(shop.ID, "Customer"))} + for i := range 5 { + m := mkModule(fmt.Sprintf("Other%d", i)) + mods = append(mods, m) + dms = append(dms, mkDomainModel(m.ID, mkEntity(m.ID, "Customer"))) + } + h := mkHierarchy(mods...) + b := &decodeCountingBackend{getDomainModel: map[model.ID]int{}, dms: dms} + if stored != nil { + b.mfs = stored(shop, mods[1]) + } + b.MockBackend = &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return mods, nil }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { return nil, nil }, + CreateMicroflowFunc: func(*microflows.Microflow) error { return nil }, + CreateNanoflowFunc: func(*microflows.Nanoflow) error { return nil }, + CreateRuleFunc: func(*microflows.Rule) error { return nil }, + } + ctx, _ := newMockCtx(t, withBackend(b.MockBackend), withHierarchy(h)) + ctx.Backend = b + return ctx, b +} + +func execOneFlow(t *testing.T, ctx *ExecContext, src string) { + t.Helper() + prog, errs := visitor.Build("mdl 1;\n" + src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + var err error + switch s := prog.Statements[0].(type) { + case *ast.CreateMicroflowStmt: + err = execCreateMicroflow(ctx, s) + case *ast.CreateNanoflowStmt: + err = execCreateNanoflow(ctx, s) + case *ast.CreateRuleStmt: + err = execCreateRule(ctx, s) + default: + t.Fatalf("unexpected statement %T", s) + } + if err != nil { + t.Fatalf("exec: %v", err) + } +} + +func TestFlowBuildDecodesOnlyTheNamedModule_Issue2094(t *testing.T) { + for kind, src := range map[string]string{ + "microflow": `create microflow Shop.ACT ($a: Shop.Customer, $l: list of Shop.Customer) returns Shop.Customer +begin + return $a; +end;`, + "nanoflow": `create nanoflow Shop.NF ($a: Shop.Customer, $l: list of Shop.Customer) returns Shop.Customer +begin + return $a; +end;`, + "rule": `create rule Shop.RL ($a: Shop.Customer, $l: list of Shop.Customer) returns boolean +begin + return true; +end;`, + } { + t.Run(kind, func(t *testing.T) { + ctx, b := decodeCountingCtx(t, nil) + execOneFlow(t, ctx, src) + t.Logf("%s: ListDomainModels=%d GetDomainModel=%v", kind, b.listDomainModels, b.getDomainModel) + if b.listDomainModels != 0 { + t.Errorf("building the %s decoded every domain model %d times; want 0 (only Shop's is needed)", kind, b.listDomainModels) + } + for id, n := range b.getDomainModel { + if n > 1 { + t.Errorf("domain model of %s decoded %d times in one build; want once", id, n) + } + } + }) + } +} + +// Finding a stored microflow by qualified name decodes only the microflows of +// that local name, and still prefers the live one over an excluded twin and +// the module asked for over another module's namesake. +func TestFindMicroflowByQualifiedNameDecodesOnlyNamesakes_Issue2094(t *testing.T) { + var live *microflows.Microflow + ctx, b := decodeCountingCtx(t, func(shop, other *model.Module) []*microflows.Microflow { + mk := func(id string, mod *model.Module, name string, excluded bool) *microflows.Microflow { + mf := µflows.Microflow{ContainerID: mod.ID, Name: name, Excluded: excluded} + mf.ID = model.ID(id) + return mf + } + live = mk("live", shop, "ACT", false) + return []*microflows.Microflow{ + mk("excluded", shop, "ACT", true), + mk("namesake", other, "ACT", false), + live, + mk("unrelated", shop, "Other", false), + } + }) + got := findMicroflowByQualifiedName(ctx, "Shop.ACT") + if got != live { + t.Fatalf("found %v, want the live Shop.ACT", got) + } + if b.listMicroflows != 0 { + t.Errorf("finding one microflow decoded every microflow %d times; want 0", b.listMicroflows) + } + if got := findMicroflowByQualifiedName(ctx, "Shop.Missing"); got != nil { + t.Errorf("Shop.Missing: found %v, want nil", got.ID) + } +} + +// A backend that cannot read one module's domain model (the mock answers nil) +// still resolves through the full listing, and a failing listing is reported +// as the backend error it is, not as a missing entity. +func TestFlowEntityResolverFallbackAndFailure_Issue2094(t *testing.T) { + shop := mkModule("Shop") + cust := mkEntity(shop.ID, "Customer") + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{shop}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{mkDomainModel(shop.ID, cust)}, nil + }, + })) + r := newFlowEntityResolver(ctx) + if got := r.resolve(ast.QualifiedName{Module: "Shop", Name: "Customer"}); got != cust.ID { + t.Errorf("fallback resolve = %q, want %q", got, cust.ID) + } + if got := r.resolve(ast.QualifiedName{Module: "Nope", Name: "Customer"}); got != "" { + t.Errorf("unknown module resolved to %q", got) + } + + failing, _ := newMockCtx(t, withBackend(&mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{shop}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, fmt.Errorf("disk on fire") }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return nil, nil }, + }), withHierarchy(mkHierarchy(shop))) + prog, errs := visitor.Build("mdl 1;\ncreate microflow Shop.ACT ($a: Shop.Customer) begin end;") + if len(errs) > 0 { + t.Fatal(errs[0]) + } + err := execCreateMicroflow(failing, prog.Statements[0].(*ast.CreateMicroflowStmt)) + if err == nil || !strings.Contains(err.Error(), "disk on fire") { + t.Errorf("a failing domain model read gave %v; want the backend error", err) + } +} From a7f760350a73364937d0feb81917a11e32305d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 21:49:51 +0000 Subject: [PATCH 15/16] fix(test): test --local beside a --test-endpoint dev loop chains to the real after-startup and keeps the dev loop's endpoint Closes ako/mxcli#2119. The test run read the dev loop's MxTest.RegisterEndpoint as the project's after-startup microflow and chained its own endpoint to it, to itself: the scratch runtime failed with a StackOverflowError and no test ran. Its cleanup then dropped the dev loop's endpoint while the setting still named it, and the dev loop's next build failed with CE1613. - A live host's restore record says what the endpoint replaced; the test run chains to that (projectState.hosted/chain, classifyHostedEndpoint). - Its cleanup removes only its test microflows and keeps the dev loop's endpoint, Java source and setting. - An abandoned endpoint is removed before capture, as `run` does; one with no record of what it replaced is refused instead of chained to. - A test run that builds rewrites the deployment/ folder it shares with the dev loop. On release over an unchanged model the watcher now builds its own back once (holdResync) when the deployment moved, so a following --attach is not refused for a restart. Verified on an 11.12.1 app with the issue's reproduction (app after-startup Spike.ASU_App): main gave StackOverflowError and a failed dev-loop build; with this change the tests ran, the endpoint and setting survived, the dev loop rebuilt its deployment once, and --attach (in-app and mixed) then ran; run stop restored Spike.ASU_App. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- ...1-10-test-local-under-hosted-endpoint.json | 1 + CHANGELOG.md | 1 + cmd/mxcli/docker/runlocal.go | 12 ++- cmd/mxcli/docker/watchhold.go | 34 +++++++-- cmd/mxcli/docker/watchhold_test.go | 28 +++++++ cmd/mxcli/testrunner/hosted_endpoint_test.go | 73 +++++++++++++++++++ cmd/mxcli/testrunner/runner.go | 62 +++++++++++++++- cmd/mxcli/testrunner/runner_endpoint.go | 6 ++ docs-site/src/tools/running-tests.md | 9 +++ 9 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-local-under-hosted-endpoint.json create mode 100644 cmd/mxcli/testrunner/hosted_endpoint_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-local-under-hosted-endpoint.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-local-under-hosted-endpoint.json new file mode 100644 index 000000000..0f7ba0bc5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-10-test-local-under-hosted-endpoint.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-10", "symptom": "ako/mxcli#2119: `mxcli test --local` while `run --local --watch --test-endpoint` ran: the scratch runtime's after-startup recursed (MicroflowException: java.lang.StackOverflowError), no test ran, and the dev loop's next build failed with CE1613 'MxTest.RegisterEndpoint no longer exists'. After the fix, a following --attach was refused: 'this change needs a runtime restart'.", "cause": "captureProjectState took the current after-startup microflow, already the dev loop's MxTest.RegisterEndpoint, as the project's own; the test run's endpoint (same name) chained to it, i.e. to itself. With MxTest pre-existing, the endpoint cleanup still dropped RegisterEndpoint, the Java action and its source, then set the setting back to the captured RegisterEndpoint. Separately, a test run that builds rewrites deployment/, which it shares with the dev loop by necessity (mxbuild cannot move it); a hold released over an unchanged model left that deployment as the test run's, and the next attach's build wanted a restart.", "file": "cmd/mxcli/testrunner/runner.go (projectState.hosted/chain, classifyHostedEndpoint, RestoreAbandonedEndpoint before capture), cmd/mxcli/testrunner/runner_endpoint.go (cleanup leaves a hosted endpoint), cmd/mxcli/docker/watchhold.go (Deployment identity, holdResync), cmd/mxcli/docker/runlocal.go", "insight": "'The current after-startup microflow' is not 'the project's after-startup microflow' once anything swaps it; the swapper's restore record is the only place the original lives, so every reader of the setting must consult it. And a cleanup keyed on 'did I create the module' is wrong when someone else owns a document inside it. The follow-on deployment drift was found only by running the next command in the workflow (attach) after the fix, not by the reported reproduction: verify the workflow, not just the repro. Measured: restoring deployment/model/ by copy is not a fix (it trips the stale-model check), so the dev loop resyncs with one build, which after --local was a restart.", "refs": ["ako/mxcli#2119", "ako/mxcli#2117", "ako/mxcli#2105"], "test": "cmd/mxcli/testrunner/hosted_endpoint_test.go, cmd/mxcli/docker/watchhold_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index af5352660..519aa5831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`mxcli test --local` beside a `run --local --test-endpoint` dev loop runs, and leaves the project buildable** (ako/mxcli#2119) — the test run read the dev loop's `MxTest.RegisterEndpoint` as the project's after-startup microflow and chained its own endpoint to it, to itself: the scratch runtime failed to boot with a `StackOverflowError` and no test ran. Its cleanup then dropped the dev loop's endpoint while the setting still named it, so the dev loop's next build failed with CE1613. A live host's restore record now says what the endpoint replaced, and the test run chains to that; its cleanup removes only its test microflows and leaves the dev loop's endpoint, Java source and setting as it found them. An endpoint whose run died is removed first, as `run` does, and one with no record of what it replaced is refused rather than chained to. When the test run rebuilt the `deployment/` folder both share, the dev loop builds its own back once the run ends (a reload or a restart), so a following `--attach` is not refused for a restart. - **A test run no longer rebuilds or restarts the dev loop it shares a project with** (ako/mxcli#2063, field test on ako/mxcli#2104) — `mxcli test --local`, and `--attach` with a test that has to be built, write their test microflows into the project a `run --local --watch` dev loop is watching, so the dev loop rebuilt for the injection and its cleanup, and applied it by restarting the runtime, which drops every browser session, `--hub` preview included; on a small app the restart itself failed (`EOFException`). The test run now holds the dev loop's watcher (`.mxcli/watch-hold.json`) while it owns the project, `--attach` now puts the project file back byte for byte as `--local` already did, and on release the watcher compares the model with the one the hold began from: the same model is not built at all, and `run status` / `run wait` read it as covered. An edit made before or during the run is still built once it ends, and a hold whose run died is ignored. - **A dev loop hosting the test endpoint no longer reports a change it will never build, and a leftover endpoint is not committed unnoticed** (review on ako/mxcli#2101) — `run --local --watch --test-endpoint` showed "change pending" in `run status` for as long as it ran, and `run wait` waited into its timeout: the boot build scaffolds `themesource/mxtest/` for the endpoint's module, which the watcher already knew was build output (ako/mxcli#2033) but the run state's readers judged by the unfiltered signal. The scaffold is now recorded in `run-state.json`, and `run status` and `run wait` use the watcher's signal; an edit to those files still counts. A run killed with its container never removes the endpoint, so the project kept `AfterStartupMicroflow: 'MxTest.RegisterEndpoint'` until a `run stop` nobody ran. Any `mxcli run` now removes an abandoned endpoint before booting, not only the next `--test-endpoint` run, and `run` and `check -p` warn while the setting is still the endpoint's with no run hosting it, with `run stop` as the fix, or the two commands that undo it by hand when the restore record went too. - **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 1424608be..c98949c1f 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -1502,6 +1502,7 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, b gen := 1 holds := &watchHoldGate{projectPath: opts.ProjectPath} holdAnnounced := false + resync := false ticker := time.NewTicker(opts.PollInterval) defer ticker.Stop() @@ -1541,10 +1542,19 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, b fmt.Fprintf(w, " the test run (pid %d) is done; the model changed meanwhile, so rebuilding\n", hold.PID) opts.State.Held(0) holdAnnounced = false + case holdResync: + // The model is the one this app serves, but the test run's build + // replaced the deployment both share; build it back now, as a + // reload, rather than leave the next apply to find it. + fmt.Fprintf(w, " the test run (pid %d) is done; it rebuilt the shared deployment, so building this app's back\n", hold.PID) + opts.State.Held(0) + holdAnnounced = false + resync = true } - if !now.After(last) { + if !now.After(last) && !resync { continue } + resync = false // Wait for the writer to finish before reading the model. // // An `mxcli exec` of a real script rewrites the .mpr and many diff --git a/cmd/mxcli/docker/watchhold.go b/cmd/mxcli/docker/watchhold.go index 3185981ab..cf975a0b9 100644 --- a/cmd/mxcli/docker/watchhold.go +++ b/cmd/mxcli/docker/watchhold.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "io" "io/fs" "os" @@ -42,8 +43,24 @@ type WatchHold struct { // Source is the dev loop's change signal (SourceMTimeOf) when the hold // began. An edit the watcher had not built yet makes it newer than the // watcher's baseline, and then the release builds even if the digest matches. - Source time.Time `json:"source"` - Started time.Time `json:"started"` + Source time.Time `json:"source"` + // Deployment identifies deployment/model/model.mdp when the hold began. A + // test run that builds writes the deployment the dev loop also serves from + // (mxbuild cannot put it elsewhere), so a model that came back unchanged can + // still leave a deployment that is the test run's: then the watcher builds + // once, to put its own back (ako/mxcli#2119). + Deployment string `json:"deployment,omitempty"` + Started time.Time `json:"started"` +} + +// deploymentIdentity is the size and modification time of the deployment's +// model, or "" when there is none. +func deploymentIdentity(projectPath string) string { + fi, err := os.Stat(filepath.Join(filepath.Dir(projectPath), "deployment", "model", "model.mdp")) + if err != nil { + return "" + } + return fmt.Sprintf("%d:%d", fi.Size(), fi.ModTime().UnixNano()) } func watchHoldPath(projectPath string) string { @@ -58,7 +75,8 @@ func HoldWatch(projectPath string, pid int) error { return err } st, _ := ReadRunState(projectPath) - h := WatchHold{PID: pid, Digest: digest, Source: SourceMTimeOf(projectPath, st), Started: time.Now()} + h := WatchHold{PID: pid, Digest: digest, Source: SourceMTimeOf(projectPath, st), + Deployment: deploymentIdentity(projectPath), Started: time.Now()} body, err := json.Marshal(h) if err != nil { return err @@ -165,6 +183,7 @@ const ( holdWait // a live hold: do not build holdAbsorbed // released, and the model is as before: move the baseline, do not build holdReleased // released, and something changed: build as usual + holdResync // released over the same model, but the run rebuilt the shared deployment: build once ) // tick reports what the watcher should do now. last is the watcher's baseline, @@ -185,8 +204,11 @@ func (g *watchHoldGate) tick(last time.Time) (holdVerdict, *WatchHold) { if h.Source.After(last) { return holdReleased, h } - if d, err := ModelDigest(g.projectPath); err == nil && d == h.Digest { - return holdAbsorbed, h + if d, err := ModelDigest(g.projectPath); err != nil || d != h.Digest { + return holdReleased, h + } + if deploymentIdentity(g.projectPath) != h.Deployment { + return holdResync, h } - return holdReleased, h + return holdAbsorbed, h } diff --git a/cmd/mxcli/docker/watchhold_test.go b/cmd/mxcli/docker/watchhold_test.go index 1aad333c0..8a812def6 100644 --- a/cmd/mxcli/docker/watchhold_test.go +++ b/cmd/mxcli/docker/watchhold_test.go @@ -140,3 +140,31 @@ func TestRunStateCovered(t *testing.T) { t.Errorf("Covered moved the source back to %v", st.Last.Source) } } + +// ako/mxcli#2119: a test run that builds writes the deployment the dev loop +// serves from. The model comes back unchanged, but the deployment is the test +// run's, and the next --attach found a build it could only apply by restart. +// So the watcher builds once (a reload) when the deployment moved under it. +func TestWatchHoldGate_ResyncsADeploymentTheRunRebuilt(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + touch(t, mpr, time.Now().Add(-time.Hour)) + mdp := filepath.Join(dir, "deployment", "model", "model.mdp") + touch(t, mdp, time.Now().Add(-time.Hour)) + old := holdAlive + holdAlive = func(int) bool { return true } + t.Cleanup(func() { holdAlive = old }) + if err := HoldWatch(mpr, 4242); err != nil { + t.Fatal(err) + } + g := &watchHoldGate{projectPath: mpr} + last := time.Now() + if v, _ := g.tick(last); v != holdWait { + t.Fatalf("verdict %v, want holdWait", v) + } + touch(t, mdp, time.Now()) // the test run's build + ReleaseWatch(mpr) + if v, _ := g.tick(last); v != holdResync { + t.Errorf("released over the same model with a rebuilt deployment: verdict %v, want holdResync", v) + } +} diff --git a/cmd/mxcli/testrunner/hosted_endpoint_test.go b/cmd/mxcli/testrunner/hosted_endpoint_test.go new file mode 100644 index 000000000..39fc0310a --- /dev/null +++ b/cmd/mxcli/testrunner/hosted_endpoint_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "strings" + "testing" +) + +// ako/mxcli#2119: `test --local` while a dev loop hosts the endpoint read the +// dev loop's MxTest.RegisterEndpoint as the project's after-startup microflow, +// chained its own endpoint to it — to itself — and the scratch runtime failed to +// boot with a StackOverflowError. A live host's restore record holds what the +// endpoint replaced, and that is what a test run chains to. +func TestClassifyHostedEndpoint(t *testing.T) { + alive := func(int) bool { return true } + dead := func(int) bool { return false } + rec := &restoreRecord{PID: 77, AfterStartup: "ServiceDesk.ASU_App"} + + hosted, chain, err := classifyHostedEndpoint("/p/App.mpr", endpointStartupFlow, rec, alive) + if err != nil || !hosted || chain != "ServiceDesk.ASU_App" { + t.Errorf("live host: hosted=%v chain=%q err=%v, want true, ServiceDesk.ASU_App, nil", hosted, chain, err) + } + st := projectState{afterStartup: endpointStartupFlow, hosted: hosted, hostedChain: chain} + if st.chain() == endpointStartupFlow { + t.Error("the run would chain its endpoint to itself") + } + + // The project's own after-startup is chained as before. + if hosted, _, err := classifyHostedEndpoint("/p/App.mpr", "ServiceDesk.ASU_App", rec, alive); hosted || err != nil { + t.Errorf("an ordinary after-startup: hosted=%v err=%v", hosted, err) + } + if (projectState{afterStartup: "M.ASU"}).chain() != "M.ASU" { + t.Error("an ordinary after-startup is not chained") + } + + // The endpoint's flow with no live owner is a leftover nothing may chain to. + withAfterStartup(t, endpointStartupFlow) + for _, c := range []struct { + name string + rec *restoreRecord + alive func(int) bool + }{{"dead owner", rec, dead}, {"no record", nil, alive}} { + if _, _, err := classifyHostedEndpoint("/p/App.mpr", endpointStartupFlow, c.rec, c.alive); err == nil || + !strings.Contains(err.Error(), endpointStartupFlow) { + t.Errorf("%s: err = %v, want a refusal naming %s", c.name, err, endpointStartupFlow) + } + } +} + +// The cleanup of a run under a hosting dev loop removes its test microflows and +// puts the setting back, but leaves the dev loop's endpoint: dropping it left +// the setting naming a microflow that no longer existed (CE1613). +func TestEndpointCleanupCommands_LeavesAHostedEndpoint(t *testing.T) { + st := projectState{afterStartup: endpointStartupFlow, hosted: true, hostedChain: "M.ASU"} + cmds := endpointCleanupCommands(st, []string{testFlowPrefix + "test_1"}, true) + joined := strings.Join(cmds, "\n") + if strings.Contains(joined, "DROP MICROFLOW "+endpointStartupFlow) || strings.Contains(joined, "DROP JAVA ACTION") || + strings.Contains(joined, "DROP MODULE") { + t.Errorf("the hosted endpoint is dropped:\n%s", joined) + } + if !strings.Contains(joined, "DROP MICROFLOW "+testFlowPrefix+"test_1") || + !strings.Contains(joined, "AfterStartupMicroflow: '"+endpointStartupFlow+"'") { + t.Errorf("the run's own test flows or the setting are not restored:\n%s", joined) + } + + // Control: an unhosted run with a pre-existing MxTest module still drops + // its endpoint, as before. + own := endpointCleanupCommands(projectState{afterStartup: "M.ASU"}, nil, true) + if !strings.Contains(strings.Join(own, "\n"), "DROP MICROFLOW "+endpointStartupFlow) { + t.Errorf("an unhosted run no longer removes its endpoint: %v", own) + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index c8703eab5..c4fd2b378 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -294,6 +294,12 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. // must succeed: without it cleanup cannot tell an existing MxTest module from // the one it is about to create, nor restore the original after-startup — and // the generated startup flow needs to know what to chain. + // An endpoint a dead run left behind would be captured as the project's own + // after-startup and chained to (ako/mxcli#2119); take it out first, as `run` + // does. + if _, err := RestoreAbandonedEndpoint(opts.ProjectPath, w); err != nil { + return nil, err + } state, err := captureProjectState(opts.ProjectPath) if err != nil { return nil, fmt.Errorf("capturing project state: %w", err) @@ -309,7 +315,7 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. } fmt.Fprintln(w, "Generating test endpoint and test microflows...") - chain := state.afterStartup + chain := state.chain() if opts.SkipAppStartup { chain = "" } @@ -335,7 +341,9 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. finish := func(result *SuiteResult, runErr error) (*SuiteResult, error) { fmt.Fprintln(w, "Cleaning up...") cleanupErr := cleanupEndpoint(opts.ProjectPath, state, cleanupSuite, w) - removeGeneratedJavaSource(opts.ProjectPath, w) + if !state.hosted { // the hosting dev loop's endpoint keeps its source + removeGeneratedJavaSource(opts.ProjectPath, w) + } restoreProjectFile(state, cleanupErr, w) reportCleanup(w, cleanupErr, survivingTestFlows(opts.ProjectPath)) if cleanupErr == nil { @@ -361,7 +369,10 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. return finish(nil, fmt.Errorf("preparing project for the test run (%s): %w", cmd, err)) } } - fmt.Fprintln(w, " "+describeStartup(state.afterStartup, opts.SkipAppStartup)) + fmt.Fprintln(w, " "+describeStartup(state.chain(), opts.SkipAppStartup)) + if state.hosted { + fmt.Fprintf(w, " the dev loop hosting %s keeps it; this run removes only its test microflows\n", endpointStartupFlow) + } // --watch keeps the runtime and the build server up and re-runs on every // change, so it owns the loop — including printing each run's results, which @@ -660,6 +671,47 @@ type projectState struct { // already exist. A pre-existing MxTest module belongs to the user and must // survive cleanup with everything but the generated TestRunner intact. createdMxTest bool + + // hosted reports that a live `run --local --test-endpoint` dev loop owns the + // endpoint already in the project: afterStartup is then that endpoint's + // MxTest.RegisterEndpoint, and hostedChain the after-startup microflow it + // chains to — the project's own (ako/mxcli#2119). A test run chains to + // hostedChain, and its cleanup leaves the dev loop's endpoint as it found it. + hosted bool + hostedChain string +} + +// chain is the after-startup microflow a run's endpoint chains to: the +// project's own, never the endpoint's own startup flow — which a run made while +// a dev loop hosts the endpoint read as "the after-startup microflow", chained +// to itself, and failed to boot with a StackOverflowError (ako/mxcli#2119). +func (st projectState) chain() string { + if st.hosted { + return st.hostedChain + } + return st.afterStartup +} + +// classifyHostedEndpoint decides what an after-startup setting that is already +// the endpoint's means. rec is the restore record a hosting run wrote; alive +// tells whether its owner still runs. +// +// The endpoint's own startup flow with a live owner is hosted: chain to what the +// owner recorded. With no live owner it is a leftover nothing should chain to — +// the caller removes an abandoned one first, so what remains has no record of +// what it replaced, and the run refuses rather than guess. +func classifyHostedEndpoint(projectPath, afterStartup string, rec *restoreRecord, alive func(int) bool) (hosted bool, chain string, err error) { + if afterStartup != endpointStartupFlow { + return false, "", nil + } + if rec != nil && rec.PID > 0 && alive(rec.PID) { + return true, rec.AfterStartup, nil + } + msg := LeftoverEndpointWarning(projectPath) + if msg == "" { + msg = "the project's after-startup microflow is " + endpointStartupFlow + ", the mxcli test endpoint's, and no run of this project is hosting it" + } + return false, "", fmt.Errorf("%s", msg) } // captureProjectState reads everything cleanup needs to restore, before anything @@ -671,6 +723,10 @@ func captureProjectState(projectPath string) (projectState, error) { return st, fmt.Errorf("reading after-startup setting: %w", err) } st.afterStartup = af + rec, _, _ := readRestoreRecord(projectPath) + if st.hosted, st.hostedChain, err = classifyHostedEndpoint(projectPath, af, rec, pidAlive); err != nil { + return st, err + } exists, err := moduleExists(projectPath, mxTestModule) if err != nil { diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index 2a53444be..d774d58b8 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -256,6 +256,12 @@ func endpointCleanupCommands(st projectState, flows []string, mxTestPresent bool for _, name := range flows { cmds = append(cmds, "DROP MICROFLOW "+name) } + // A dev loop hosting the endpoint still serves it: dropping it left the + // after-startup setting naming a microflow that no longer existed, and the + // dev loop's next build failed with CE1613 (ako/mxcli#2119). + if st.hosted { + return cmds + } return append(cmds, "DROP MICROFLOW "+endpointStartupFlow, "DROP JAVA ACTION "+endpointRegisterAction, diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index bcf8c6030..f977397f6 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -185,6 +185,15 @@ rebuild, or restart its app, for the injection, `run status` shows the watcher finds nothing to build. An edit made in the meantime is built once the run ends. A hold whose run died is ignored. +A run that builds (`--local`, or a built test under `--attach`) writes the +`deployment/` folder the dev loop serves from too — mxbuild cannot put it +elsewhere. When it did, the dev loop builds its own back once the run ends, so a +later `--attach` finds the app's build; that one build can be a restart (measured: +after `--local`, a restart; after a mixed `--attach`, a reload). A `--test-endpoint` +dev loop's endpoint is left in place by `--local`, which chains to the project's +own after-startup microflow rather than to the endpoint (ako/mxcli#2119). For a +gate in the middle of such a loop, `--attach` costs nothing to the running app. + `--attach` skips even the first boot, at one cost worth knowing: the tests run against the running app's database rather than a scratch one, so they can leave data behind in the app you are looking at. It needs the dev loop to have been From e78f568acb4499edcc462a7841b60e0b0ac4f6d7 Mon Sep 17 00:00:00 2001 From: Ako Date: Tue, 10 Nov 2026 21:56:36 +0000 Subject: [PATCH 16/16] docs(changelog): place the #2094 executor entry apart from the v1 one --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa27fef8a..5e4a106c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **Building a flow no longer decodes the whole project to resolve one entity or find one microflow** (ako/mxcli#2094, mendixlabs/mxcli#1795) — each entity-typed parameter and return type of a microflow was resolved by decoding every domain model in the project, and `create or modify microflow` found the stored flow by decoding every microflow, so `exec` and `check` paid a fixed cost per statement that grew with the project. A flow now reads only the domain model of the module a type names, and the microflows with that name. On a 3,400-unit project a one-activity `create or modify` went from 0.86 s to 0.58 s (0.12 s together with the MPR v1 listing fix), and on a 1,054-unit v2 project from 0.061 s to 0.020 s. - **A test run no longer rebuilds or restarts the dev loop it shares a project with** (ako/mxcli#2063, field test on ako/mxcli#2104) — `mxcli test --local`, and `--attach` with a test that has to be built, write their test microflows into the project a `run --local --watch` dev loop is watching, so the dev loop rebuilt for the injection and its cleanup, and applied it by restarting the runtime, which drops every browser session, `--hub` preview included; on a small app the restart itself failed (`EOFException`). The test run now holds the dev loop's watcher (`.mxcli/watch-hold.json`) while it owns the project, `--attach` now puts the project file back byte for byte as `--local` already did, and on release the watcher compares the model with the one the hold began from: the same model is not built at all, and `run status` / `run wait` read it as covered. An edit made before or during the run is still built once it ends, and a hold whose run died is ignored. +- **Building a flow no longer decodes the whole project to resolve one entity or find one microflow** (ako/mxcli#2094, mendixlabs/mxcli#1795) — each entity-typed parameter and return type of a microflow was resolved by decoding every domain model in the project, and `create or modify microflow` found the stored flow by decoding every microflow, so `exec` and `check` paid a fixed cost per statement that grew with the project. A flow now reads only the domain model of the module a type names, and the microflows with that name. On a 3,400-unit project a one-activity `create or modify` went from 0.86 s to 0.58 s (0.12 s together with the MPR v1 listing fix), and on a 1,054-unit v2 project from 0.061 s to 0.020 s. - **A dev loop hosting the test endpoint no longer reports a change it will never build, and a leftover endpoint is not committed unnoticed** (review on ako/mxcli#2101) — `run --local --watch --test-endpoint` showed "change pending" in `run status` for as long as it ran, and `run wait` waited into its timeout: the boot build scaffolds `themesource/mxtest/` for the endpoint's module, which the watcher already knew was build output (ako/mxcli#2033) but the run state's readers judged by the unfiltered signal. The scaffold is now recorded in `run-state.json`, and `run status` and `run wait` use the watcher's signal; an edit to those files still counts. A run killed with its container never removes the endpoint, so the project kept `AfterStartupMicroflow: 'MxTest.RegisterEndpoint'` until a `run stop` nobody ran. Any `mxcli run` now removes an abandoned endpoint before booting, not only the next `--test-endpoint` run, and `run` and `check -p` warn while the setting is still the endpoint's with no run hosting it, with `run stop` as the fix, or the two commands that undo it by hand when the restore record went too. - **`check --references`, `exec` and `lint` flag a `create or modify` agent without a selected model or MCP service without an endpoint** (ako/mxcli#2089) — MDL-AGENT02/03 skipped every `create or modify`, the form project scripts and `describe` use to create a document, so a new agent or service written that way passed every check, and nothing flagged a stored one. `check --references` and `exec` now warn when the statement leaves nothing to keep (a new document, one the script dropped, or a stored one without the value); `mxcli lint` reports stored agents without a selected model (MPR014) and consumed MCP services without an endpoint (MPR015). The agent half is skipped for AgentEditorCommons before 2.2, which does not read the value. - **The agent catalog counts tools one way and shows a consumed MCP service's endpoint** (ako/mxcli#2090) — `CATALOG.AGENT_TOOLS` also held an agent's MCP services, so it disagreed with `AGENTS.ToolCount` and the documented "tools that run without approval" query counted MCP services as tools. They are now only in `CATALOG.AGENT_MCP_SERVICES`, as `MCPServiceCount` counts them. `CATALOG.CONSUMED_MCP_SERVICES` gains `Endpoint` and `AuthenticationMicroflow`, so a service that connects nowhere (#2060) can be queried, and `CATALOG.REFS` records the endpoint constant (`endpoint`) and the credentials microflow (`call`), which `list callers` reported as uncalled.