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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .claude/skills/mendix/write-lint-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-167 | Available Query Functions |
| 168-574 | Object Properties |
| 575-588 | Helper Functions |
| 589-675 | Common Patterns |
| 676-684 | Validation |
| 68-170 | Available Query Functions |
| 171-577 | Object Properties |
| 578-591 | Helper Functions |
| 592-678 | Common Patterns |
| 679-687 | Validation |

<!-- /skill-index -->
# Writing Custom Starlark Lint Rules
Expand Down Expand Up @@ -111,10 +111,13 @@ Without this, a rule that reads a full-only table under a fast build gets
| `strings(language = None)` | list of catalog_string | User-facing and documentary text, one row per text and language; pass `language` (`"nl_NL"`) to narrow. An untranslated language has **no row**. Needs a FULL catalog, which `mxcli lint` builds automatically for a rule that calls it |
| `layouts()` | list of layout | All non-system layouts |
| `published_rest_operations()` | list of published_rest_operation | Operations of published REST services, with the microflow behind each |
| `workflows()` | list of workflow | The user's workflows, with their parameter entity and activity counts |
| `workflow_activities()` | list of workflow_activity | Every activity of the user's workflows — boundary-event paths and event sub-processes included — with its kind and the microflow, page or workflow each slot names. Use it for "a system task's microflow is prefixed `WF_`": `refs_to()` says only that the workflow calls a microflow, not from which kind of activity |

The fields of `module`, `folder`, `annotation`, `association`, `entity_event_handler`,
`navigation_menu_item`, `jar_dependency`, `jar_file`, `catalog_string`, `layout` and
`published_rest_operation` are in [catalog-tables.md](catalog-tables.md).
`navigation_menu_item`, `jar_dependency`, `jar_file`, `catalog_string`, `layout`,
`published_rest_operation`, `workflow` and `workflow_activity` are in
[catalog-tables.md](catalog-tables.md).

### Graph-analysis functions (architecture rules)

Expand Down
35 changes: 35 additions & 0 deletions .claude/skills/mendix/write-lint-rules/catalog-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,41 @@ Returned by `published_rest_operations()`.
| `deprecated` | bool | Marked deprecated |
| `module_name` | string | `"Sales"` |

### workflow
Returned by `workflows()`.

| Property | Type | Example |
|----------|------|---------|
| `id` | string | Workflow ID |
| `qualified_name` | string | `"Sales.ApproveOrder"` |
| `name` | string | `"ApproveOrder"` |
| `module_name` | string | `"Sales"` |
| `description` | string | Documentation text |
| `parameter_entity` | string | Qualified name of the context entity: `"Sales.Order"` |
| `activity_count` | int | Activities in every flow of the workflow, boundary-event paths and event sub-processes included |
| `user_task_count` | int | User tasks and multi-user tasks |
| `microflow_call_count` | int | System tasks and call-microflow activities |
| `decision_count` | int | Decisions |

### workflow_activity
Returned by `workflow_activities()`, each workflow's activities in walk order: the main flow, each activity followed by the paths of its boundary events, then the event sub-processes. A slot that names nothing is `""`.

| Property | Type | Example |
|----------|------|---------|
| `id` | string | Activity ID |
| `workflow_qualified_name` | string | `"Sales.ApproveOrder"` |
| `module_name` | string | `"Sales"` |
| `name` | string | `"ReviewOrder"` |
| `caption` | string | `"Review the order"` |
| `activity_type` | string | `"UserTask"`, `"MultiUserTask"`, `"SystemTask"`, `"CallMicroflow"`, `"AIAgentTask"`, `"CallWorkflow"`, `"Decision"`, `"ParallelSplit"`, `"JumpTo"`, `"WaitForTimer"`, `"WaitForNotification"`, `"StartWorkflow"`, `"EndWorkflow"`, … |
| `microflow` | string | The microflow a `SystemTask`, `CallMicroflow` or `AIAgentTask` runs |
| `user_source_microflow` | string | A user task's targeting microflow (users or groups) |
| `on_created_microflow` | string | A user task's on-created microflow |
| `completion_microflow` | string | A multi-user task's completion microflow |
| `page` | string | A user task's page |
| `user_task_entity` | string | A user task's task entity |
| `called_workflow` | string | The workflow a `CallWorkflow` activity calls |

### widget
Returned by `widgets()` (full catalog — auto-detected).

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **`CATALOG.WORKFLOW_ACTIVITIES`, and `workflows()` / `workflow_activities()` for lint rules** (ako/mxcli#1295, mendixlabs/mxcli#1269) — one row per workflow activity, boundary-event paths and event sub-processes included, with its kind (user task, multi-user task, system task, call microflow, AI agent task, call workflow, …) and the microflow, page or workflow each of its slots names. A rule can now check "a system task's microflow is prefixed `WF_SYS_`": `refs` records only that the workflow calls the microflow, not from which activity. Catalog schema 48.
- **`make bench`: a benchmark of what an agent waits for, and `MXCLI_STATS_FILE`** — a fixed session of mxcli commands (open, author entities, microflows and pages, describe, catalog refresh, check, a one-document edit, a bulk change, a re-run, a rename), one process per command, on PedApp and on PedApp with a generated app seeded into it. `make bench BENCH_RUNTIME=<Mendix version>` adds a session that validates and builds the project with mx and then runs it — `docker check`, `docker build`, boot, a hot change, an entity change, a page check, an attached and a cold test run — on a project `mxcli new` creates, with mxbuild and the runtime installed by mxcli when they are not cached. With `BASELINE=<other mxcli>` the two binaries run interleaved on the same machine and each step reports the ratio; the nightly `bench` job does that against the previous nightly and commits the record to the `benchmarks` branch. It also records counts that do not depend on the machine: unit files read and unit files changed. `MXCLI_STATS_FILE=<path>` makes any mxcli command append one JSON line per closed project connection with its unit-file reads and its writes offered and landed. See "Agent Dev-Loop Benchmark" in the docs.
- **`mxcli test --local --reuse-build` skips the injection and the build when nothing changed** (mendixlabs/mxcli#1783, closes mendixlabs/mxcli#1782) — a local test run injects its test microflows, builds and boots; when the project, the test files and mxcli are unchanged, the deployment the previous run left is already this run's build, so `--reuse-build` boots it directly (about 12 s against 60 s, measured by the author). The decision is a content hash of the project folder (outputs, caches, `theme-cache/`, and any `.git`/`node_modules` left out), the generated test MDL, the mxcli binary and the mxbuild override, pinned to the identity of `deployment/model/model.mdp`, so a deployment rebuilt by anything else is never taken for the test build. Not under `--watch` or `--skip-build`.
- **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.
Expand Down
31 changes: 31 additions & 0 deletions docs-site/src/tools/catalog-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,37 @@ JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId
WHERE f.FlowType = 'AnnotationFlow';
```

### CATALOG.WORKFLOW_ACTIVITIES

One row per activity of a workflow, in walk order: the main flow, each
activity followed by the paths of its boundary events, then the event
sub-processes. Populated by every catalog build. A slot that names nothing is
NULL.

| Column | Description |
|--------|-------------|
| `Id` | The activity's ID |
| `WorkflowId`, `WorkflowQualifiedName`, `ModuleName` | The workflow it belongs to (`CATALOG.WORKFLOWS.Id`) |
| `Sequence` | Walk order within the workflow |
| `Name`, `Caption` | As in Studio Pro |
| `ActivityType` | `UserTask`, `MultiUserTask`, `SystemTask`, `CallMicroflow`, `AIAgentTask`, `CallWorkflow`, `Decision`, `ParallelSplit`, `JumpTo`, `WaitForTimer`, `WaitForNotification`, `StartWorkflow`, `EndWorkflow`, and the event sub-process and path-end kinds |
| `Microflow` | The microflow a system task, call-microflow activity or AI agent task runs |
| `UserSourceMicroflow` | A user task's targeting microflow (users or groups) |
| `OnCreatedMicroflow` | A user task's on-created microflow |
| `CompletionMicroflow` | A multi-user task's completion microflow |
| `Page`, `UserTaskEntity` | A user task's page and task entity |
| `CalledWorkflow` | The workflow a call-workflow activity calls |

`CATALOG.REFS` records that a workflow calls a microflow; this table says from
which kind of activity and which slot.

```sql
-- Microflows that system tasks run, without the WF_SYS_ prefix
SELECT WorkflowQualifiedName, Name, Microflow
FROM CATALOG.WORKFLOW_ACTIVITIES
WHERE ActivityType = 'SystemTask' AND Microflow NOT LIKE '%.WF_SYS_%';
```

### CATALOG.PAGES

Information about pages and their properties.
Expand Down
104 changes: 104 additions & 0 deletions mdl/catalog/builder_workflow_activities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: Apache-2.0

package catalog

import (
"github.com/mendixlabs/mxcli/mdl/backend/wfnames"
"github.com/mendixlabs/mxcli/sdk/workflows"
)

// workflowActivityRow is one row of workflow_activities (ako/mxcli#1295).
type workflowActivityRow struct {
ID, Name, Caption, ActivityType string
Microflow, UserSourceMicroflow, OnCreatedMicroflow, CompletionMicroflow string
Page, UserTaskEntity, CalledWorkflow string
}

// workflowActivityRows lists a workflow's activities in walk order with the
// documents each one names. A lint rule that wants "every microflow a system
// task runs" or "every page a user task opens" reads it, where refs says only
// that the workflow calls the microflow, not from which kind of activity.
func workflowActivityRows(wf *workflows.Workflow) []workflowActivityRow {
var out []workflowActivityRow
wfnames.WalkActivities(wf, func(act workflows.WorkflowActivity) {
r := workflowActivityRow{
ID: string(act.GetID()),
Name: act.GetName(),
Caption: act.GetCaption(),
ActivityType: act.ActivityType(),
}
switch a := act.(type) {
case *workflows.UserTask:
if a.IsMulti {
r.ActivityType = "MultiUserTask"
}
r.Page, r.UserTaskEntity, r.OnCreatedMicroflow = a.Page, a.UserTaskEntity, a.OnCreated
switch us := a.UserSource.(type) {
case *workflows.MicroflowBasedUserSource:
r.UserSourceMicroflow = us.Microflow
case *workflows.MicroflowGroupSource:
r.UserSourceMicroflow = us.Microflow
}
if a.CompletionCriteria != nil {
r.CompletionMicroflow = a.CompletionCriteria.Microflow
}
case *workflows.CallMicroflowTask:
if a.IsAgent {
r.ActivityType = "AIAgentTask"
}
r.Microflow = a.Microflow
case *workflows.SystemTask:
r.Microflow = a.Microflow
case *workflows.CallWorkflowActivity:
r.CalledWorkflow = a.Workflow
}
out = append(out, r)
})
return out
}

// buildWorkflowActivities writes workflow_activities.
func (b *Builder) buildWorkflowActivities(wfs []*workflows.Workflow) (int, error) {
stmt, err := b.tx.Prepare(`
INSERT INTO workflow_activities_data (Id, Sequence, WorkflowId, WorkflowQualifiedName, ModuleName,
Name, Caption, ActivityType, Microflow, UserSourceMicroflow, OnCreatedMicroflow,
CompletionMicroflow, Page, UserTaskEntity, CalledWorkflow, ProjectId, SnapshotId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return 0, err
}
defer stmt.Close()
projectID, snapshotID := b.snapshotMeta()

count := 0
for _, wf := range wfs {
if !b.own(string(wf.ID)) {
continue
}
moduleName := b.hierarchy.getModuleName(b.hierarchy.findModuleID(wf.ContainerID))
qualifiedName := moduleName + "." + wf.Name
for i, r := range workflowActivityRows(wf) {
if _, err := stmt.Exec(r.ID, i, string(wf.ID), qualifiedName, moduleName,
r.Name, r.Caption, r.ActivityType,
nullIfEmpty(r.Microflow), nullIfEmpty(r.UserSourceMicroflow), nullIfEmpty(r.OnCreatedMicroflow),
nullIfEmpty(r.CompletionMicroflow), nullIfEmpty(r.Page), nullIfEmpty(r.UserTaskEntity),
nullIfEmpty(r.CalledWorkflow), projectID, snapshotID); err != nil {
return count, err
}
count++
}
}
b.disown()
return count, nil
}

// nullIfEmpty is a reference column: the referenced document's qualified
// name, or NULL when the slot is empty — one rule since schema 47: absent is
// NULL (ako/mxcli#2093, docRefName).
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
87 changes: 87 additions & 0 deletions mdl/catalog/builder_workflow_activities_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: Apache-2.0

package catalog

import (
"fmt"
"strings"
"testing"

modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk"
)

// ako/mxcli#1295 (mendixlabs/mxcli#1269): a lint rule could not ask which kind
// of workflow activity runs a microflow — "a system task's microflow is
// prefixed WF_SYS_", "a user task's targeting microflow WF_TGT_". refs records
// only that the WORKFLOW calls the MICROFLOW, and workflows_data only counts
// activities.

func TestWorkflowActivityRows_KindAndSlots_Issue1295(t *testing.T) {
var got []string
for _, r := range workflowActivityRows(everySlotWorkflow()) {
got = append(got, fmt.Sprintf("%s mf=%s src=%s created=%s done=%s page=%s wf=%s",
r.ActivityType, r.Microflow, r.UserSourceMicroflow, r.OnCreatedMicroflow,
r.CompletionMicroflow, r.Page, r.CalledWorkflow))
}
want := []string{
// The main flow, each activity followed by its boundary-event paths.
"UserTask mf= src=M.TargetUsers created=M.OnCreated done= page=M.TaskPage wf=",
"CallMicroflow mf=M.InUserTaskBoundary src= created= done= page= wf=",
"MultiUserTask mf= src=M.TargetGroups created= done=M.Completion page= wf=",
"CallMicroflow mf=M.MainCall src= created= done= page= wf=",
"SystemTask mf=M.InCallBoundary src= created= done= page= wf=",
"CallWorkflow mf= src= created= done= page= wf=M.SubWorkflow",
"CallMicroflow mf=M.InCallWorkflowBoundary src= created= done= page= wf=",
"WaitForNotification mf= src= created= done= page= wf=",
"CallMicroflow mf=M.InWaitBoundary src= created= done= page= wf=",
// Then the event sub-process.
"InterruptingNotificationEventSubProcessStart mf= src= created= done= page= wf=",
"CallMicroflow mf=M.InEventSubProcess src= created= done= page= wf=",
"UserTask mf= src= created= done= page=M.SubProcessTaskPage wf=",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
}

// The table is written by a fast build: model-quality rules run on one.
func TestTestApp_WorkflowActivitiesInFastBuild_Issue1295(t *testing.T) {
be := modelsdkbackend.New()
if err := be.Connect(copyTestApp(t)); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { be.Disconnect() })
cat, err := New()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cat.Close() })
if err := NewBuilder(cat, be).Build(nil); err != nil {
t.Fatalf("catalog build: %v", err)
}

// workflow.UserTaskEventHandle is a user task's on-created microflow
// (TestTestApp_WorkflowHandlersAreNotDead).
rows := queryRows(t, cat, `SELECT WorkflowQualifiedName, ActivityType FROM workflow_activities
WHERE OnCreatedMicroflow = 'workflow.UserTaskEventHandle'`)
if len(rows) == 0 {
t.Fatal("no activity has workflow.UserTaskEventHandle as its on-created microflow")
}
for _, r := range rows {
if typ := r[1].(string); typ != "UserTask" && typ != "MultiUserTask" {
t.Errorf("%v: on-created microflow on a %s", r[0], typ)
}
}
// An empty slot is NULL, not ''.
if n := queryRows(t, cat, `SELECT COUNT(*) FROM workflow_activities WHERE Microflow = '' OR Page = ''`); n[0][0].(int64) != 0 {
t.Errorf("%v rows store an empty slot as ''", n[0][0])
}
// Every workflow's activity count matches its rows.
for _, r := range queryRows(t, cat, `SELECT w.QualifiedName, w.ActivityCount,
(SELECT COUNT(*) FROM workflow_activities a WHERE a.WorkflowId = w.Id)
FROM workflows w`) {
if r[1].(int64) != r[2].(int64) {
t.Errorf("%v: ActivityCount %v but %v activity rows", r[0], r[1], r[2])
}
}
}
6 changes: 6 additions & 0 deletions mdl/catalog/builder_workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ func (b *Builder) buildWorkflows() error {
b.disown()

b.report("Workflows", count)

activities, err := b.buildWorkflowActivities(wfs)
if err != nil {
return err
}
b.report("Workflow activities", activities)
return nil
}

Expand Down
1 change: 1 addition & 0 deletions mdl/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ func (c *Catalog) Tables() []string {
"CATALOG.SNAPSHOTS",
"CATALOG.OBJECTS",
"CATALOG.WORKFLOWS",
"CATALOG.WORKFLOW_ACTIVITIES",
"CATALOG.CONSUMED_ODATA_SERVICES",
"CATALOG.PUBLISHED_ODATA_SERVICES",
"CATALOG.BUSINESS_EVENT_SERVICES",
Expand Down
Loading
Loading