Skip to content

Sync from ako/mxcli: microflow canvas layout authoring (#884), marketplace cache, dev container fix - #886

Merged
ako merged 12 commits into
mendixlabs:mainfrom
ako:main
Aug 13, 2026
Merged

Sync from ako/mxcli: microflow canvas layout authoring (#884), marketplace cache, dev container fix#886
ako merged 12 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Title

Sync from ako/mxcli: microflow canvas layout authoring (#884), marketplace cache, dev container fix

Description

Sync of ako/mxcli:main — 7 non-merge commits since #885, 31 files, +2202 / −99. mendixlabs/main is fully contained in this branch (0 commits ahead), so the diff is additive and conflict-free.

Three threads: a feature set closing most of #884, two contained fixes, and two proposals.


1. Microflow canvas layout — upstream #884 (3 commits)

The reporter generates TOF-convention microflows from MDL and controls layout with @position/@anchor. Three of their four problems are fixed here. Every claim was checked against the code and against Mendix's own storage first; all four stand up, two turned out worse than filed, and one asked for something Mendix does not store.

Unknown annotations were silently dropped → MDL059

The grammar accepts any @name and the visitor's switch had no default arm. The reporter found this as "@size(600, 300) parses without error but produces no effect". The sharper case is a typo of an annotation that works:

@postion(999, 111)   -- check passed; the position was silently discarded

On a workflow whose whole point is scripted layout, that throws away the thing being authored and reports success. Now an error, enforced by exec as well as check.

Annotations are read reflectively: fifteen statement types carry them, and a hand-written type switch that skips the sixteenth reintroduces the silent drop one level up. Two tests parse the source to keep this honest — one asserts every *ActivityAnnotations field is named so the accessor reaches it, the other parses the visitor's own case labels and compares them against the validator's list in both directions. Checked against 407 real MDL blocks for false positives.

A hand-curved edge was flattened by any rewrite → @curve

The request asked for edge waypoints. Mendix stores none: a flow's shape is two bezier control vectors on its Microflows$BezierCurve line. So the syntax maps to what exists:

@curve(from: (40, -90), to: (-40, 90))

Both writers already emitted the vectors and the legacy parser already read them, but nothing could set them, so they defaulted to "0;0" and every rebuild flattened the curve — measured by patching "40;-90" into the stored line and re-running the same script unchanged. Data loss, not a missing feature. Needed no grammar change: name: (x, y) is already annotationParenValue.

The merge that closes a split was unpositionable → @merge

A statement's @position belongs to the split, so its end-if join had no annotation and routinely landed on a neighbouring activity.

@merge(900, 400)

All three split builders read through one mergePosition helper, so the override cannot be honoured at one split type and ignored at another.

MPR008 compared nodes on different canvases

The rule flattened a LoopedActivity's children — positioned relative to the container — into the same list as absolute canvas coordinates, and reported overlaps that cannot happen on screen. Verified in the stored BSON: an outer activity at 200;230 beside a loop child at 141;130. Now one plane per canvas.

Note for the reporter: their quoted pair does not reproduce. (141,130) vs (134,230) is dy=100 against activityBoxHeight = 60, so it never fired. The mechanism is real; that evidence was not.

The design constraint shared by all three

Both new annotations are applied at one choke point. Threading a curve alongside the anchor would mean editing all seven sites that create a flow, and threading the describer's splitMergeMap to emit @merge would mean editing ten-plus call sites of emitObjectAnnotations — the multi-site change that fails silently when one is missed. Instead: the curve is recorded against the activity and stamped in a single pass after the graph is built, and commonMergeAfter finds a split's merge by walking its branches using data already in scope. The walk is bounded and node-capped, because a retry loop makes the flow graph cyclic.

DESCRIBE emits both, which is what makes them real rather than write-only — the reporter's "DESCRIBE output is identical before and after manual adjustment" was the read half of the same bug. @merge was implemented and reverted from the first PR for exactly this reason, and re-landed only once the describe half existed.

2. Fixes (2 commits)

  • Marketplace reference cache stored a whole blank Mendix app — 34 MB per entry, of which widgets/ (9.6 MB), themesource/ (6.4 MB), theme-cache/ (2.1 MB) and javascriptsource/ (1.6 MB) are never read. Both consumers take the .mpr and nothing beside it. Entries are now model-only.
  • The generated dev container installed postgresql-client only — so mxcli run --local --ensure-db had no service to start and no superuser, while psql on PATH made the container look correctly provisioned. Adds the server package, asserted for both the docker and podman variants.

3. Proposals (2 commits, docs only)

  • mxcli init --solution — two existing proposals assume a repo where several .mpr projects sit under one workspace, and nothing creates that shape: init discovers only the first .mpr and writes a dev container per project, so two projects yield two competing containers. Scoped deliberately to the repo-shape prerequisite, with the orchestration half left in warm-loop slice 5 as a non-goal.
  • mxcli developer panelanalyze-runtime.md already covers joining logs, metrics, traces and the catalog; what it cannot supply is which spans belong to the thing the user just did. Proposes a panel that captures the interaction plus an in-process OTLP collector and an MCP surface. Deliberately avoids the unverified push API: the panel records, the agent pulls.

Verification

mxbuild 11.6.6, both engines (modelsdk and legacy), for the #884 work:

Check Result
mx check on every fixture 0 errors
@curve on the annotated activity's flow 40;-90 / -40;90, 0;0 elsewhere
@merge stored at 900;400 as authored
Re-executing DESCRIBE's own output both preserved
MPR008 cross-canvas / same-canvas silent / still reported
MDL corpus (407 blocks) no false positives
unit suite, -tags integration green

Two process notes worth carrying upstream. My first @curve tests called the functions directly; deleting both call sites left them green — they proved the functions worked and nothing about the wiring. Every annotation added here now has a test that fails when unwired, verified by removing each call site in turn. Separately, an intermediate "no MPR008 violations" reading turned out to be the scratch project having been reaped rather than the fix working, so that A/B was redone with a pre-fix binary against a rebuilt project.

Scope of my own testing: I authored and verified the #884 thread end-to-end. The marketplace cache, dev container fix and both proposals came from other sessions; they build and pass the suite in this branch, but their own measurements are theirs, not re-run by me.

Still open in #884

Problem 1, container Size. Diagnosis confirmed: measureStatementsSpan derives width from statement count, never from child positions, which is exactly the reporter's evidence of identical child positions producing different Size. Deriving it from a child bounding box is a layout-engine change affecting every generated flow's geometry, and wants its own proposal rather than a patch. Note that @size(600, 300) is now rejected rather than ignored, so adding it later means updating the known-annotation list — the drift test fails loudly if only one side changes, which is the intent.

claude and others added 12 commits August 13, 2026 18:40
Two of the four problems in upstream mendixlabs#884.

## An unknown annotation was silently dropped

The grammar accepts any @name and extractMicroflowAnnotations' switch had no
default arm, so an unrecognised annotation parsed and did nothing. The reporter
found this as "@SiZe(600, 300) parses without error but produces no effect".

The sharper case is a TYPO of an annotation that does work:

  @postion(999, 111)   -- check passed; the position was silently discarded

On a workflow whose entire point is scripted canvas layout, that throws away the
thing being authored and reports success. Both forms are now MDL059, an error
rather than a warning, and enforced by exec as well as check.

Annotations are read generically rather than through a type switch: fifteen
statement types carry them, and a switch that skips the sixteenth reintroduces
the silent drop one level up. TestEveryActivityAnnotationsFieldIsNamedAnnotations
parses the AST package's own source for every *ActivityAnnotations field and
asserts the accessor reaches it, and TestKnownAnnotationsMatchTheVisitor parses
the visitor's case labels and compares both directions, so the restated list
cannot drift from what the visitor implements.

Checked against the real corpus for false positives: 405 MDL blocks across
.claude/skills/mendix and docs-site/src all still pass.

## MPR008 compared nodes on different canvases

The rule recursed into a LoopedActivity's ObjectCollection and appended the
children into the same flat list as the microflow's own canvas, then compared
every pair. A LoopedActivity's children are positioned RELATIVE to the container
— verified in the stored BSON, an outer activity at 200;230 beside a loop child
at 141;130 — so it reported overlaps that cannot happen on screen. A false
positive is worse than silence for a rule whose job is to be trusted about
positions.

overlapPlanes now returns one plane per canvas and the pair loop iterates within
each. The container itself stays on its parent's plane; only its children move.
Extracting it also gives the rule its first real coverage: its test file carried
a note that the logic "cannot be unit-tested without building a mock mpr.Reader",
which is why this had none.

Verified both directions against a real project with a pre-fix binary: the
cross-canvas pair is reported by the old binary and not the new one, while a
genuine same-canvas pair at (300,200)/(310,210) is still reported by both.

The reporter's own quoted pair does not reproduce — (141,130) vs (134,230) is
dy=100 against activityBoxHeight=60, so it never fired; the mechanism is real but
that evidence was not, and a constructed pair was needed.

Refs upstream mendixlabs#884. The remaining two problems (container Size from child
positions, and bezier control vectors surviving a rewrite) are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
A cache entry was 34 MB because it stored whatever `mx create-project`
produced — a whole blank Mendix app. Most of it is never read. Measured on
Administration at 11.12.1:

  PackageRef.mpr      14 MB   read
  widgets/           9.6 MB   never read
  themesource/       6.4 MB   never read
  theme-cache/       2.1 MB   never read (compiled CSS)
  javascriptsource/  1.6 MB   never read

Both consumers take the .mpr and nothing beside it: SnapshotModule opens
it, and PerformUpdate takes the reference's model from it while taking the
module's bundled widgets from the .mpk — deliberately, because the
reference project's widgets/ also holds the blank template's copies.

Entries are now model-only (.mpr plus mprcontents/, kept in case a
reference ever stays MPR v2). 34 MB -> 14 MB, and warm runs got faster
because there is less to copy: the same diff went 13s -> 9s, with the same
answer, 21 of 21 elements unchanged and 6 an upgrade would touch.

The bound rises 6 -> 12 on the back of it. Twelve is what a six-module
update sweep builds, base and target each, so a whole sweep now stays
cached at ~170 MB instead of half of it being evicted.

This is a constraint on future changes, and isModelFile says so: a cached
reference is model-only, so anything that starts reading a sibling
directory of the reference .mpr will see it on a cache miss and not on a
hit — findings that come and go. The blank-project cache deliberately
keeps its whole tree, since `mx module-import` reads all of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`mxcli run --local` needs a real database, and `--ensure-db` provisions one
in-container by starting the local PostgreSQL service and creating the app
role + database through a `sudo -u postgres` superuser. The generated
Dockerfile installed `postgresql-client` only, so on a freshly built
`mxcli init` dev container there was no service to start and no superuser
— `--ensure-db` failed even though `psql` was on PATH, which made the
container look correctly provisioned.

Add the `postgresql` server package alongside the client, and assert it in
`TestGenerateDockerfile_PostgresServer` for both the docker and podman
variants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKdcQRPJZxTWq87SH34WYy
Two existing proposals assume a repo where several .mpr projects live one
level deep under a single workspace — warm-loop slice 5 (`run --solution`)
and the multi-project tree view. Nothing creates that repo: `mxcli init`
discovers only the first .mpr in a directory and writes a dev container
per project, so initialising two projects yields two competing containers
and VS Code attaches to one at a time.

Propose `mxcli init --solution`: one root dev container whose forwardPorts
follow slice 5's port-triple rule, a root agent context indexing the
projects, per-project init without a nested dev container, a SessionStart
hook covering every project, and a `mxcli.solution.yaml` skeleton for
slice 5 to consume.

Scoped deliberately to the repo-shape prerequisite. The orchestration half
(manifest schema, `run --solution`, sibling-URL wiring) stays in slice 5
and is listed as a non-goal rather than duplicated. Records two findings
for slice 5: `--constant` is now the primitive its constant wiring needs,
and app-to-app links must use loopback, since an owner-gated hub answers a
cookie-less OData call with a login page.

Also documents a bug found while writing this up: the SessionStart hook
marker is a substring match, so `init` in a second project sees the first
project's hook, reports "already present", and silently never bootstraps
the second app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKdcQRPJZxTWq87SH34WYy
…analysis

analyze-runtime.md already covers joining logs, metrics, traces, and the
catalog. What it cannot supply is the correlation key: which spans belong
to the thing the user just did in the browser. That key exists only in the
user's head and reaches the agent as prose, so the agent guesses at a time
window and greps.

Propose a panel injected into the app under development that captures the
interaction — widget, page, time window, XHR, action — plus an in-process
OTLP collector so spans are queryable without an external one, plus an
mxcli MCP server exposing both to the agent.

Deliberately avoids the unverified push API. Sending a prompt into a
running Claude Code session needs a "post to session X" surface that is
not exposed to an agent today and is outside mxcli's control, so v1
inverts the flow: the panel records, the agent pulls, and answers land in
the Claude Code conversation. In-panel replies stay a purely additive
follow-up.

Two design points worth the space. Correlation via inbound W3C traceparent
would be exact, but is unverified — outbound propagation is known to work,
which is suggestive, not proof — so a window-based fallback ships first and
a spike gates the precise path. And the panel must be gated on the viewer
matching Backend.Owner rather than on preview reachability: external
testers require --require-auth=false, which would otherwise expose the
panel to everyone with the URL.

Rejects shipping the panel as a JavaScript action: theme apply is
deliberately model-free so it cannot break a build, and a panel written
into the model would ship into production artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKdcQRPJZxTWq87SH34WYy
The third problem in upstream mendixlabs#884, and the one that loses data.

Mendix stores NO waypoints. A sequence flow's shape is two bezier control
vectors on its Microflows$BezierCurve line — the tangent handles at each end —
so a hand-curved edge is a pair of (x, y) offsets, not a polyline. The request
asked for waypoints; that shape does not exist, and @Curve maps to what does.

Both writers already emitted the vectors and the legacy parser already read
them, but nothing could set them, so they defaulted to "0;0" and every rebuild
flattened the curve. Measured before the fix by patching "40;-90" into the
stored line and re-running the same script unchanged: it came back "0;0".

  @Curve(from: (40, -90), to: (-40, 90))

Needed no grammar change — `name: (x, y)` is already annotationParenValue, the
same shape the association anchors use (mendixlabs#872). Either end may be omitted, which
leaves that end straight; "0;0" is straight and DESCRIBE omits it, so an
untouched flow describes exactly as it did before. A malformed coordinate is
MDL060 rather than a silently straightened edge.

The curve is recorded against the ACTIVITY and stamped onto its outgoing flows
in a single pass once the graph is complete. Threading it alongside the anchor
would have meant editing all seven sites that create a flow, and missing one
silently straightens that edge; applyPendingAnnotations already runs at every
activity, so there is one place to get right.

DESCRIBE now emits @Curve, which is what makes the round trip real: the
reporter's "DESCRIBE output is identical before and after manual adjustment" was
the read half of the same bug. The modelsdk engine also reads the Line back,
which it never did.

Verified on mxbuild 11.6.6, both engines: 0 errors, the authored curve on
exactly the annotated activity's outgoing flow and "0;0" everywhere else, and
re-executing DESCRIBE's own output preserving both vectors.

Both call sites are covered by tests that fail when unwired — deleting them left
the direct-call unit tests green, which is exactly the trap of a test that only
passes against fixed code.

Still open: a curve drawn in Studio Pro is preserved only once the script names
it, since a rebuilt flow has no stable identity to match on. DESCRIBE now
surfaces it so it can be captured. Container Size (mendixlabs#884 problem 1) and merge-node
positioning (problem 3) remain untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
…qxghl0

Dev container PostgreSQL server + two proposals for multi-project solutions
Cache the reference model, not the whole reference project
fix(microflow): reject unknown annotations, scope MPR008 per canvas, and author flow curvature (mendixlabs#884)
The last of upstream mendixlabs#884's four problems, and the one I reverted from #143
because it was only half done.

The statement's own @position belongs to the SPLIT, so the implicit merge that
closes it had no annotation of its own: three split builders computed its
position with no override, and it routinely landed on top of a neighbouring
activity with no way to move it.

  @merge(900, 400)

All three split builders (addIfStatement, addEnumSplit,
addStructuredInheritanceSplit) now read through one mergePosition helper, so the
override cannot be honoured at one split type and silently ignored at another.

The half that was missing before is DESCRIBE. Without it the layout pass
recomputes the merge on the next exec and puts it straight back on its
neighbour — the same round-trip data loss as mendixlabs#872, mendixlabs#881 and mendixlabs#882, introduced by
the change meant to fix it, which is why the authoring-only version was pulled.

Finding the merge to describe is the interesting part. The describer's own
split->merge map is not in scope where annotations are emitted, and threading it
there would mean editing ten-plus call sites of emitObjectAnnotations — the
multi-site change that fails silently when one is missed. commonMergeAfter walks
the split's branches instead and takes the nearest merge reachable from ALL of
them, using the flowsByOrigin and activityMap already passed in. The walk is
bounded per branch and node-capped, because a retry loop makes the flow graph
cyclic.

Verified on mxbuild 11.6.6, both engines: the merge lands at 900;400, mx check
reports 0 errors, and re-executing DESCRIBE's own output keeps it there. Both
call sites are covered by tests that fail when unwired — verified by removing
each in turn, since calling mergePosition and emitMergeAnnotation directly
passes either way.

mendixlabs#884 problem 1 (container Size from child positions) remains open: that is a
layout-engine change and wants its own proposal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
feat(microflow): position a split's merge node with @merge (mendixlabs#884)
@ako
ako merged commit fa886b8 into mendixlabs:main Aug 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants