diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index bcc4bc2e8..596f2c9a4 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -507,3 +507,8 @@ extracting `OffsetExpression`/`LimitExpression`. | An import activity's Range — Studio Pro's **All / First / Custom** — is absent from `DESCRIBE MICROFLOW`, and a `limit`/`offset` set in Studio Pro does not survive an mxcli round trip | Worse than "undescribed". `Microflows$ImportMappingCall.Range` is polymorphic — `ConstantRange{SingleObject}` (All/First) or `CustomRange{LimitExpression, OffsetExpression}` (Custom) — and mxcli wrote only the first and read only `SingleObject`. So **Custom was unrepresentable**, a bounded import became unbounded on any rewrite, and all three settings described identically, so describe→edit→exec silently changed the activity | `mdl/grammar/domains/MDLMicroflow.g4` (`importMappingRange`) + `MDLLexer.g4` (`FIRST`) + `MDLSettings.g4` (keyword list), `mdl/visitor/visitor_import_export_mapping.go`, `mdl/ast/ast_microflow.go`, `sdk/microflows/microflows_actions.go` (`RangeSingleObject`, `RangeSingleObjectOf`), `mdl/executor/cmd_microflows_builder_calls.go` (`addImportFromMappingAction`), `mdl/executor/cmd_microflows_format_action.go` (`formatImportMappingRange`), `mdl/backend/modelsdk/microflow_write.go` (`importMappingRangeToGen`) + `microflow_read_actions.go`, `sdk/mpr/writer_microflow_actions.go` (`importMappingRange`) + `parser_microflow_actions.go` | **The Range and the result variable's CARDINALITY are separate axes** — the first fix folded them and every `all` wrote a `ListType` against an object-rooted mapping, which mxbuild rejects with **CE0243** ("the mapping used to return 'List of X' but now returns 'X'"). Mendix's own `FeedbackModule.SUB_Feedback_PostToAppInsights` settles it: `ConstantRange{SingleObject:false}` against an **ObjectType** variable. The stored `VariableType` is the authority on cardinality; the range is its own flag (hence `RangeSingleObject *bool`, nil = "not authored, fall back"). **Count the write sites before declaring a serialization fixed** — the `ImportMappingCall` is built at THREE places (`importXmlActionToGen`, the REST `ResultHandlingMapping` case, and the legacy writer), and patching two let a limit reach the model while a `ConstantRange` was still written; extract one helper. **DESCRIBE must emit one of the three, never nothing** — silence re-enters the builder's inference, and an object-rooted mapping set to All (Studio Pro's default) comes back as First. **`first` is not `limit 1`**: one binds an OBJECT, the other a one-element LIST, so they cannot share syntax. **A platform rule you cannot author a positive case for is documentation, not validation** — Mendix rejects `offset` on a non-list mapping with **CE6100** while accepting `limit`, and mxcli cannot currently author a list-rooted import mapping (array-root mappings emit CE5015), so the constraint is documented rather than guessed at in a checker. Tests `mdl/executor/cmd_microflows_import_range_test.go`, `mdl/backend/modelsdk/microflow_import_range_test.go`, `sdk/mpr/parser_microflow_import_range_test.go`, example `mdl-examples/bug-tests/881-import-mapping-range.mdl`. upstream #881 | | `CREATE IMPORT MAPPING` is reported to "not preserve JSON member names": `total` becomes `Total`, `camelCase` becomes `CamelCase`, `__Value` becomes `__ValueItem`. The import then fails at runtime with `key not found: Path(QName(None,),None,)` at `MappingCache.storeValueMappingElement` | The capitalisation is **Mendix's own** and not the defect. A `JsonStructures$JsonElement` stores TWO names — `Path` (the raw JSON key, `(Object)\|uuid`, which the RUNTIME resolves by) and `ExposedName` (derived by capitalising the initial, and suffixing `Item` on an array's item object, which Studio Pro DISPLAYS). The real defect: mxcli's DESCRIBE emits `ExposedName` while its builder resolved only raw keys, so mxcli's own output did not round-trip — re-executing a DESCRIBE FABRICATED a path (`(Object)\|Total`; for arrays the `\|(Object)` item marker vanished entirely, leaving `(Object)\|EntityInstances\|__ValueItem`) and zeroed MaxOccurs. Export mappings had the identical bug. Separately, mxcli wrote `IsDefaultType` on every `ValueMappingElement` (a property only the OBJECT element types own) and cloned the JSON structure's `OriginalValue` — the SAMPLE value from the snippet — onto every mapping element, where Studio Pro leaves it empty | `mdl/executor/cmd_import_mappings.go` (`jsonSchemaIndex`, `newJSONSchemaIndex`, `resolve`, `memberNames`; `buildImportMappingElementModel` now returns an error), `mdl/executor/cmd_export_mappings.go` (same), `mdl/backend/modelsdk/mapping_write.go` + `sdk/mpr/writer_import_mapping.go` + `sdk/mpr/writer_export_mapping.go` (drop `IsDefaultType` from the value writers); `cmd_import_mappings.go` again for the `OriginalValue` clone | **A blank Mendix app is a free Studio-Pro-authored fixture** — `FeedbackModule.IMM_PostResponse` + `JSON_AppInsightsResponse` settle "what does Studio Pro actually write?" with no Studio Pro and no marketplace download: they store `ExposedName "Uuid"` against `Path "(Object)|uuid"`. Author the SAME mapping over the SAME structure in the SAME module with mxcli and diff the two units — the only variable left is which tool wrote it. **Run the reporter's repro as written before believing the diagnosis**: theirs produced paths byte-identical to the structure's, so their MDL was not the failing input; the corruption needed a DESCRIBE in the middle. **DESCRIBE emitting a different name than the parser accepts is a round-trip bug even when both are "correct"** — the fix is to accept BOTH spellings, not to change what DESCRIBE prints, because the exposed name is the one Studio Pro shows. **Never fabricate a path for an unresolved member**: the invented path passed `mxcli check` and surfaced only in mxbuild (CE5015) or at runtime, so the tool that wrote it reported success — refuse, and list the spellings that would have worked. **`generated/metamodel` decides property ownership**: `isDefaultType` is declared on `Import/ExportObjectMappingElement` and on neither `ValueMappingElement`, and per CLAUDE.md's overlay rule an extra property is the shape mxbuild tolerates and Studio Pro will not open — so a green build proves nothing here. **Count the samples before calling a difference a defect**: two Studio-Pro-authored mappings in a blank app (~15 value elements) all write `OriginalValue: ""` while their structures carry 17 non-empty samples, which is what makes "the sample belongs to the structure" a measurement rather than an opinion. **Running the reporter's repro is how you find out it is not a reproduction**: theirs PASSED at runtime on 11.6.6 and 11.13.0, so the runtime error they see needs something their standalone file does not carry — say so instead of claiming the fix. And **control the harness before believing its failure**: an early runtime run failed until the control (Studio Pro's own mapping, same harness) failed identically, which located the fault in the test's missing module-role grants, not in the mapping. Tests `mdl/executor/cmd_mappings_member_resolution_test.go`, `mdl/backend/modelsdk/mapping_isdefaulttype_test.go`, example `mdl-examples/bug-tests/882-mapping-member-names.mdl`. upstream #882 | | `create validation rule …` parses and silently writes nothing, so a regex document cannot be bound to an attribute from MDL at all — every form (regex, range, required, unique, expression) reports success and leaves the model untouched | Three layers, one visible symptom. (1) The grammar rule existed with **no AST node, no visitor and no handler**. (2) Its shape was unimplementable: an `EXPRESSION` rule type Mendix does not have, an **inline** regex literal where Mendix stores a **reference to a document**, and strict `<`/`>` bounds Mendix cannot represent. (3) Underneath, `modelsdk/gen` bound `RegExRuleInfo`'s reference to BSON `RegularExpression` where Studio Pro stores `RegExIdentifier`, so the rule could not have been written even with the statement wired up | `modelsdk/gen/domainmodels/types.go` (`STORAGE-NAME OVERRIDE` on `initRegExRuleInfo` **and** `InitFromRaw`), `mdl/backend/modelsdk/domainmodel.go` (`ruleInfoFromGen`) + `domainmodel_write.go` (`ruleInfoToGen` now takes the whole rule), `sdk/domainmodel/domainmodel.go` (regex ref is a qualified NAME not an ID; Range gained the attribute-bound fields), `mdl/grammar/domains/MDLDomainModel.g4`, `mdl/ast/ast_validationrule.go`, `mdl/visitor/visitor_validationrule.go`, `mdl/executor/cmd_validationrules.go`, `mdl/executor/cmd_entities_describe.go` | **Correcting `modelsdk/gen` IS available** — this supersedes the previous row's "no `supplements.json` or `cmd/modelsdk-codegen` in this tree, so correcting gen is not currently available". Neither path has ever existed here (`git log --all` empty) and `/reference/` is gitignored, but four `STORAGE-NAME OVERRIDE` precedents already hand-patch `init`, and `TestGeneratedCodeIsFormatted` exists because gen gets hand-edited. **`generated/metamodel` is the arbiter**: `cmd/codegen` emits the storage name as the json tag (`json:"regExIdentifier"`) while gen kept the SDK name — an audit of all 570 cross-checkable types found **65 with at least one wrong key**, and the three with independent evidence (`RegularExpression.Expression`, `Attribute.GUID`, this one) were all flagged correctly. **Patch BOTH the encode and decode literal**: reverting only `init` gave a document that writes `RegularExpression` and reads `RegExIdentifier`, whose symptom is a confusing entity-rewrite refusal rather than a bad file. **The control is the proof**: same script, key reverted → `CE0135 "No regular expression specified"` on mxbuild 11.13; with the fix → 0 errors and `RegExIdentifier` on disk. **A rule type is not a rule** — `ruleInfoToGen(ruleType string)` could never rebuild a RegEx (its reference) or a Range (its bounds), so it was widened to the whole rule and the reader taught to carry the payload; a bare RuleInfo of the right `$Type` is a silent downgrade wearing the right name. **Check for a bound that is not a literal**: a Range may point at another ATTRIBUTE, which MDL cannot author and a literals-only reader would have dropped on the next rewrite. **Do not add a second spelling for something already authorable** — Required/Unique stay attribute constraints (`not null error '…'`), refused here with a pointer. **Fixing an unimplementable grammar is free when nothing could depend on it**: every old form parsed and did nothing, so the shape was replaced rather than preserved. Tests `modelsdk/gen/domainmodels/storagename_test.go`, `mdl/backend/modelsdk/validationrule_test.go`, `mdl/visitor/visitor_validationrule_test.go`, `mdl/executor/cmd_validationrules_test.go`, example `mdl-examples/doctype-tests/validation-rules.mdl` | +| An `@annotation` mxcli does not implement — `@size(600, 300)` — parses and does nothing, and layout authored through annotations silently differs from what was written | The grammar accepts any `@name`, and `extractMicroflowAnnotations`' switch had **no default arm**, so an unrecognised name was dropped in silence. Benign for an unimplemented annotation; NOT benign for a typo of an implemented one — `@postion(10, 20)` passed `check` and discarded the position | `mdl/ast/ast_microflow.go` (`ActivityAnnotations.UnknownNames`), `mdl/ast/annotations.go` (new: `StatementAnnotations`), `mdl/visitor/visitor_microflow_statements.go` (the `default:` arm), `mdl/executor/validate_microflow.go` (`checkUnknownAnnotations`, `knownActivityAnnotations`, MDL059), `mdl/executor/validate.go` (exec-enforced) | **A silent no-op is worse than an unimplemented feature** — the reporter filed "`@size` is ignored", but the same missing arm meant a typo of the annotation their whole workflow depends on threw away the layout with a clean `check`. Fix the class, not the instance. **Read the annotations generically, not through a type switch**: 15 statement types carry `Annotations`, and a hand-written switch that skips the 16th reintroduces the silent drop one level up — `StatementAnnotations` reflects, and `TestEveryActivityAnnotationsFieldIsNamedAnnotations` parses the AST package's own source for every `*ActivityAnnotations` field and asserts it is named so the accessor reaches it. **Pin a restated list against its source**: `knownActivityAnnotations` duplicates the visitor's case labels, so `TestKnownAnnotationsMatchTheVisitor` parses those labels out of the visitor and compares both directions — a name in one and not the other either rejects a working annotation or accepts a dead one. **Check a new rejection against the real corpus before believing it**: `scripts/check-skill-mdl.sh` over `.claude/skills/mendix` and `docs-site/src` (405 blocks) proved no false positives. Tests `mdl/ast/annotations_test.go`, `mdl/executor/validate_annotations_test.go`. upstream #884 | +| `mxcli lint` reports MPR008 "activities overlap" for two nodes that live on different canvases — one inside a loop, one outside — so generated layout is flagged for an overlap that cannot happen on screen | `collect` recursed into `LoopedActivity.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 — outer activity `200;230` (absolute) beside a loop child `141;130` (container-relative) | `mdl/linter/rules/mpr008_overlapping_activities.go` (`overlapPlanes`, one plane per canvas; the pair loop iterates per plane) | **A false positive on a layout rule is worse than silence** — the rule exists to be trusted about positions. **Extract the inline logic to make the fix testable**: the rule's own test file carried a note that the overlap logic "cannot be unit-tested without building a mock mpr.Reader", which is why the bug had no coverage; `overlapPlanes` is a pure function over the object tree and needs no reader. **Assert both directions** — the cross-canvas pair must stop being reported AND a genuine same-canvas pair at (300,200)/(310,210) must still be, or the fix trades a false positive for a false negative. **The reporter's own numbers did not reproduce**: (141,130) vs (134,230) is dy=100 against `activityBoxHeight = 60`, so that pair never fired; the mechanism was real but the quoted evidence was not, and a constructed pair was needed to demonstrate it. **A vanished fixture reads exactly like a fixed bug** — an intermediate "no violations" result was the scratch project having been reaped, not the fix working; the A/B was redone with a pre-fix binary against the same rebuilt project. Tests `mpr008_overlapping_activities_test.go`. upstream #884 | +| A hand-curved sequence flow does not survive a rewrite — `DESCRIBE` output is identical before and after the curve is drawn in Studio Pro, and re-running unchanged MDL straightens the edge | Mendix stores NO waypoints. A flow's shape is two bezier control vectors on its `Microflows$BezierCurve` line (`OriginControlVector`/`DestinationControlVector`, `"x;y"`). Both writers already emitted them 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-executing the same script, which returned `"0;0"` | `mdl/ast/ast_microflow.go` (`FlowCurve`, `ActivityAnnotations.Curve`/`InvalidCurves`), `mdl/visitor/visitor_microflow_statements.go` (`parseCurveAnnotation`, `annotationPointValue`), `mdl/executor/cmd_microflows_builder_annotations.go` (`curveByOrigin`, `applyFlowCurves`, and the `mergeStatementAnnotations` copy), `cmd_microflows_builder_graph.go` (the one call), `cmd_microflows_show_helpers.go` (`emitCurveAnnotation`), `mdl/backend/modelsdk/microflow.go` (read the Line back) | **Check what the storage can represent before designing the syntax** — the request asked for "edge waypoints", which do not exist; `SequenceFlow` has two control vectors, so `@curve(from: (x, y), to: (x, y))` is the only shape that maps. Same lesson as #872's anchors. **Look for an existing annotation shape before touching the grammar**: `name: (x, y)` is already `annotationParenValue`, so `@curve` needed ZERO grammar changes. **Record against the ACTIVITY and stamp flows in one pass** — threading a curve alongside the anchor would mean editing all seven sites that create a flow (`previousStmtAnchor`, `nextFlowAnchor`, branch and loop variants) and missing one silently straightens that edge; `applyPendingAnnotations` already runs at every activity, so there is exactly one place to get right. **A new field on `ActivityAnnotations` must be copied in `mergeStatementAnnotations`** — it is an explicit field-by-field copy, so the first cut parsed the curve and wrote nothing. **Unit tests that call the function directly do NOT prove the wiring**: deleting both call sites left `TestEmitCurveAnnotation` and `TestApplyFlowCurves…` green, which is the "a test that only passes against fixed code" trap in its purest form — `TestCurveReachesTheFlowThroughTheBuilder` (MDL text → real builder) and `TestCurveIsEmittedByTheAnnotationEmitter` (through `emitObjectAnnotations`) fail when unwired. **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. Tests `mdl/executor/cmd_microflows_curve_test.go`, example `mdl-examples/bug-tests/884-annotations-and-lint-planes.mdl`. upstream #884 | +| `mxcli run --local --ensure-db` fails on a freshly built `mxcli init` dev container — there is no PostgreSQL service to start and no `postgres` superuser — even though `psql` is on PATH | The generated Dockerfile installed `postgresql-client` **only**. `EnsureDatabase` starts a local service (`service postgresql start`) and provisions the role + database through `sudo -u postgres psql`, both of which need the **server** package. `psql` being present makes the container look correctly provisioned | `cmd/mxcli/tool_templates.go` (`generateDockerfile`) | Install `postgresql` alongside `postgresql-client`. **Generalisable**: when a feature shells out to a service, assert the *server* package in the image template, not the client that happens to satisfy a `LookPath` check — the CI/web image having the server is what hid this (`/usr/lib/postgresql/16` is present there but not in the generated dev container). Guarded by `TestGenerateDockerfile_PostgresServer`, which asserts the server package for both the docker and podman variants | +| The implicit merge that closes a split — the end-if join — is placed by the layout pass and cannot be moved; it routinely lands on top of a neighbouring activity, and `@position` does not address it | The statement's own `@position` belongs to the SPLIT, so the merge had no annotation of its own. Three split builders computed `mergeX/centerY` with no override (`addIfStatement`, `addEnumSplit`, `addStructuredInheritanceSplit`), and DESCRIBE emitted nothing for it, so even a hand-moved merge was recomputed on the next exec | `mdl/ast/ast_microflow.go` (`ActivityAnnotations.Merge`), `mdl/visitor/visitor_microflow_statements.go` (`case "merge"`), `mdl/executor/cmd_microflows_builder_annotations.go` (`mergePosition`, and the `mergeStatementAnnotations` copy), the three split builders, `cmd_microflows_show_helpers.go` (`emitMergeAnnotation`, `commonMergeAfter`) | **Authoring without the DESCRIBE half is not a fix** — the first attempt shipped `@merge` writing correctly and was REVERTED, because the describer drops it and the layout pass then recomputes the merge on the next exec; that is the same round-trip data loss as #872/#881/#882, introduced by the change meant to help. **Find the relationship from data already in scope rather than threading a map**: the describer's `splitMergeMap` is not available at `emitObjectAnnotations`, and threading it would mean editing ten-plus call sites (the multi-site trap); `commonMergeAfter` walks the split's branches to the nearest merge reachable from ALL of them, using the `flowsByOrigin` and `activityMap` already passed in. **Bound any walk over a flow graph** — a retry loop makes it cyclic, so the walk carries a per-branch visited set and a node cap, pinned by `TestCommonMergeAfterTerminatesOnACycle`. **One helper for every site that places the merge**, so the override cannot be honoured at one split type and ignored at another. **Test the WIRING, not the helper**: `mergePosition` and `emitMergeAnnotation` called directly pass with every call site removed — `TestMergeReachesTheCanvasThroughTheBuilder` (MDL text → real builder) and `TestMergeIsEmittedByTheAnnotationEmitter` (through `emitObjectAnnotations`) fail when unwired, verified by removing each. Tests `mdl/executor/cmd_microflows_merge_test.go`, example `mdl-examples/bug-tests/884-annotations-and-lint-planes.mdl`. upstream #884 | diff --git a/.claude/skills/mendix/download-marketplace-content.md b/.claude/skills/mendix/download-marketplace-content.md index a64ebc666..5fb771cbe 100644 --- a/.claude/skills/mendix/download-marketplace-content.md +++ b/.claude/skills/mendix/download-marketplace-content.md @@ -263,7 +263,7 @@ Administration — installed 4.3.2 (Mendix 11.12.1) **Tell the user the first one is slow.** Answering needs a reference project — a blank app with the published module imported — and `--to` needs two. Measured -on Administration at 11.12.1: **~50s** the first time, **~13s** afterwards, once +on Administration at 11.12.1: **~47s** the first time, **~9s** afterwards, once `~/.mxcli/marketplace-refs/` holds the blank app and the built references. Run `diff` before `update` rather than instead of it: the `update` reuses the base reference the `diff` just built, so the pair costs little more than the `diff`. diff --git a/cmd/mxcli/marketplace/refcache.go b/cmd/mxcli/marketplace/refcache.go index c5b1099ed..72200e1d6 100644 --- a/cmd/mxcli/marketplace/refcache.go +++ b/cmd/mxcli/marketplace/refcache.go @@ -117,15 +117,19 @@ func safeKey(s string) string { // defaultRefCacheEntries bounds the finished-reference cache. // -// An entry is a whole Mendix project: 34 MB measured for Administration, the -// smallest module in a blank app. Updating six modules builds twelve references, -// so an unbounded cache is ~400 MB of a container that may have 2 GB free — and -// running out of disk mid-update is a far worse outcome than rebuilding a -// reference, because `marketplace update` does not roll back. +// An entry is the model alone (see isModelFile) — 14 MB measured for +// Administration at 11.12.1, against 34 MB for the whole reference project. +// Twelve is what a six-module update sweep builds, base and target each, so the +// default holds a whole sweep at ~170 MB and the `update` that follows a `diff` +// still hits. // -// The blank-project cache is deliberately NOT bounded: it holds one entry per -// Mendix version, and it is the one that pays off on every single build. -const defaultRefCacheEntries = 6 +// It is bounded at all because running out of disk part way through an update is +// a far worse outcome than rebuilding a reference: `marketplace update` does not +// roll back, so a failed write leaves the module already dropped. +// +// The blank-project cache is deliberately NOT bounded: one entry per Mendix +// version, and it is the one that pays off on every single build. +const defaultRefCacheEntries = 12 // refCacheMaxEntries reads the bound, honouring MXCLI_REF_CACHE_MAX. 0 disables // pruning for anyone with disk to spare. @@ -253,12 +257,56 @@ func publishToCache(buildDir, cacheDir string) error { return nil } +// isModelFile reports whether a path inside a reference project is part of the +// MODEL, which is the only thing a cached reference is ever read for. +// +// A reference project is a whole blank Mendix app, and most of it is bulk that +// nothing here touches. Measured on Administration at 11.12.1, a 34 MB entry: +// +// 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. +// +// THIS IS A CONSTRAINT ON FUTURE CHANGES. 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 — a difference that shows up as +// findings that come and go. Extend this filter in the same commit, or store +// the whole tree again. +// +// mprcontents/ is kept even though `mx module-import` always collapses the +// reference to MPR v1: the cost is nothing when it is absent, and the failure if +// that ever changes is an unreadable model rather than a slower run. +func isModelFile(rel string) bool { + if rel == "mprcontents" || strings.HasPrefix(rel, "mprcontents"+string(filepath.Separator)) { + return true + } + return !strings.ContainsRune(rel, filepath.Separator) && strings.HasSuffix(rel, ".mpr") +} + // copyTree copies a directory tree. Used both to seed a build from the cache and // to hand a caller its own copy, so the cached tree is never the one written to. // // Symlinks are copied as symlinks; a Mendix project has none, and following them // would let a crafted package escape the destination. func copyTree(src, dst string) error { + return copyTreeFiltered(src, dst, nil) +} + +// copyTreeModelOnly copies just the model, for the finished-reference cache. +// The blank-project cache deliberately does NOT use this: a blank app is the +// input to `mx module-import`, which reads the whole tree. +func copyTreeModelOnly(src, dst string) error { + return copyTreeFiltered(src, dst, isModelFile) +} + +func copyTreeFiltered(src, dst string, keep func(rel string) bool) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -274,6 +322,12 @@ func copyTree(src, dst string) error { if rel == completeMarker { return nil } + if keep != nil && !keep(rel) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } target := filepath.Join(dst, rel) switch { diff --git a/cmd/mxcli/marketplace/refcache_test.go b/cmd/mxcli/marketplace/refcache_test.go index 2111c4252..50e3985dc 100644 --- a/cmd/mxcli/marketplace/refcache_test.go +++ b/cmd/mxcli/marketplace/refcache_test.go @@ -273,3 +273,47 @@ func TestPruneDisabledByZero(t *testing.T) { t.Errorf("a malformed bound gave %d, want the default %d", got, defaultRefCacheEntries) } } + +// TestModelOnlyCopyKeepsWhatIsRead pins the filter that makes a reference entry +// ~14 MB instead of ~34 MB. The .mpr and mprcontents/ are the model; everything +// else in a reference project is bulk nothing reads. +// +// The negative half matters as much as the positive: if widgets/ started being +// copied again the cache would quietly double in size, and if the .mpr stopped +// being copied every hit would serve an unreadable reference. +func TestModelOnlyCopyKeepsWhatIsRead(t *testing.T) { + src, dst := t.TempDir(), filepath.Join(t.TempDir(), "out") + + mustWrite(t, filepath.Join(src, "PackageRef.mpr"), "model") + mustWrite(t, filepath.Join(src, "mprcontents", "nested", "unit.mxunit"), "unit") + mustWrite(t, filepath.Join(src, "widgets", "big.mpk"), "bulk") + mustWrite(t, filepath.Join(src, "themesource", "atlas", "style.scss"), "bulk") + mustWrite(t, filepath.Join(src, "theme-cache", "compiled.css"), "bulk") + mustWrite(t, filepath.Join(src, "javascriptsource", "a.js"), "bulk") + + if err := copyTreeModelOnly(src, dst); err != nil { + t.Fatalf("copyTreeModelOnly: %v", err) + } + + if got := readFile(t, filepath.Join(dst, "PackageRef.mpr")); got != "model" { + t.Errorf(".mpr = %q, want %q — a served entry would be unreadable", got, "model") + } + if got := readFile(t, filepath.Join(dst, "mprcontents", "nested", "unit.mxunit")); got != "unit" { + t.Errorf("mprcontents unit = %q, want %q", got, "unit") + } + for _, bulk := range []string{"widgets", "themesource", "theme-cache", "javascriptsource"} { + if _, err := os.Stat(filepath.Join(dst, bulk)); !os.IsNotExist(err) { + t.Errorf("%s/ was copied into the cache entry; nothing reads it", bulk) + } + } + + // And the unfiltered copy still takes everything — it is what seeds a blank + // project, which mx module-import reads in full. + full := filepath.Join(t.TempDir(), "full") + if err := copyTree(src, full); err != nil { + t.Fatalf("copyTree: %v", err) + } + if _, err := os.Stat(filepath.Join(full, "widgets", "big.mpk")); err != nil { + t.Error("the unfiltered copy dropped widgets/; a blank project needs its whole tree") + } +} diff --git a/cmd/mxcli/marketplace/scratch.go b/cmd/mxcli/marketplace/scratch.go index 5d9677e7c..5772be7c3 100644 --- a/cmd/mxcli/marketplace/scratch.go +++ b/cmd/mxcli/marketplace/scratch.go @@ -355,7 +355,9 @@ func CacheReference(versionID, mendixVersion, refDir, mpkPath string) { if err != nil { return } - if err := copyTree(refDir, filepath.Join(staging, entryProject)); err != nil { + // Model only: see isModelFile for what is dropped, why nothing reads it, and + // what that constrains. + if err := copyTreeModelOnly(refDir, filepath.Join(staging, entryProject)); err != nil { _ = os.RemoveAll(staging) return } diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 8bc7a4f37..669655540 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -180,6 +180,31 @@ func init() { SeeAlso: []string{"microflow.error-handling"}, }) + Register(SyntaxFeature{ + Path: "microflow.layout", + Summary: "Canvas layout annotations — @position, @anchor, @curve, @caption, @color", + Keywords: []string{ + "position", "anchor", "curve", "layout", "canvas", + "annotation", "caption", "color", "excluded", "bezier", + }, + Syntax: "@position(x, y) -- the activity's centre point\n" + + "@anchor(from: right, to: left) -- which SIDE each end of the outgoing flow attaches to\n" + + "@curve(from: (40, -90), to: (-40, 90)) -- the flow's bezier control vectors\n" + + "@merge(x, y) -- the implicit merge that closes a split\n" + + "@caption 'text'\n@color Green\n@annotation 'a note'\n@excluded\n\n" + + "An unrecognised @name is an error (MDL059): it would parse and do nothing,\n" + + "so a typo of @position would silently discard the layout.\n\n" + + "Mendix stores no waypoints — a flow's shape is two control vectors, each a\n" + + "pixel offset from its end of the line. (0, 0) at both ends is straight.\n" + + "@position on a split belongs to the SPLIT, so its end-if join has its own\n" + + "annotation. Container Size is still computed, not authorable.", + Example: "create microflow MyModule.ACT_Flow ($In: String)\nreturns String as $Out\nbegin\n" + + " @position(200, 100)\n @anchor(from: bottom, to: top)\n" + + " @curve(from: (40, -90), to: (-40, 90))\n declare $Tmp String = $In;\n" + + " @position(200, 300)\n declare $Out String = $Tmp;\n return $Out;\nend;", + SeeAlso: []string{"microflow", "microflow.create"}, + }) + Register(SyntaxFeature{ Path: "microflow.mapping", Summary: "IMPORT FROM MAPPING / EXPORT TO MAPPING, and the import Range (All/First/Custom)", diff --git a/cmd/mxcli/tool_templates.go b/cmd/mxcli/tool_templates.go index ea845d67d..871335075 100644 --- a/cmd/mxcli/tool_templates.go +++ b/cmd/mxcli/tool_templates.go @@ -352,7 +352,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ return `FROM mcr.microsoft.com/devcontainers/base:bookworm -# Install Adoptium JDK 21 (required by MxBuild), Node.js 22, and utility tools +# Install Adoptium JDK 21 (required by MxBuild), Node.js 22, and utility tools. +# +# The postgresql SERVER (not just -client) is required: the standalone runtime +# 'mxcli run --local' boots needs a real database, and 'mxcli run --local +# --ensure-db' provisions it in-container by starting the local service and +# creating the role + database through a 'sudo -u postgres' superuser. With only +# postgresql-client installed there is no service to start and no superuser, so +# --ensure-db fails on a fresh container. RUN apt-get update && apt-get install -y --no-install-recommends wget apt-transport-https gpg ca-certificates curl && \ wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg && \ echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \ @@ -361,6 +368,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends wget apt-transp apt-get install -y --no-install-recommends \ temurin-21-jdk \ nodejs \ + postgresql \ postgresql-client \ kafkacat \ && apt-get clean \ diff --git a/cmd/mxcli/tool_templates_test.go b/cmd/mxcli/tool_templates_test.go index d044dc58f..3b6251f91 100644 --- a/cmd/mxcli/tool_templates_test.go +++ b/cmd/mxcli/tool_templates_test.go @@ -69,6 +69,23 @@ func TestGenerateDockerfile_Podman(t *testing.T) { } } +// TestGenerateDockerfile_PostgresServer guards the database prerequisite of +// `mxcli run --local`: --ensure-db starts a local PostgreSQL service and creates +// the role + database via a `sudo -u postgres` superuser, neither of which exists +// when only postgresql-client is installed. The client alone is easy to mistake +// for enough, so assert the server package specifically. +func TestGenerateDockerfile_PostgresServer(t *testing.T) { + for _, runtime := range []string{"docker", "podman"} { + df := generateDockerfile("MyApp", "App.mpr", runtime) + if !strings.Contains(df, "postgresql \\") { + t.Errorf("%s Dockerfile must install the postgresql SERVER package, not just postgresql-client — 'run --local --ensure-db' cannot provision a database without it", runtime) + } + if !strings.Contains(df, "postgresql-client") { + t.Errorf("%s Dockerfile should keep postgresql-client (psql is used to probe and provision)", runtime) + } + } +} + // TestGenerateDockerfile_PlaywrightArm64 guards the arm64 Playwright provisioning // fix: browsers must be installed via @playwright/cli's bundled playwright-core // (not a transient "npx playwright"), into a world-readable shared cache, with a diff --git a/docs-site/src/guides/marketplace.md b/docs-site/src/guides/marketplace.md index 4f30c79a5..4cdee5c87 100644 --- a/docs-site/src/guides/marketplace.md +++ b/docs-site/src/guides/marketplace.md @@ -206,10 +206,10 @@ the version being moved to. Two caches under `~/.mxcli/marketplace-refs/` keep that off the clock: -| Cache | Keyed by | Saves | -|---|---|---| -| `blank/` | Mendix version | the `mx create-project` in **every** reference build | -| `ref/` | published version UUID + Mendix version | the whole reference, on a repeat | +| Cache | Keyed by | Holds | Saves | +|---|---|---|---| +| `blank/` | Mendix version | a whole blank app (~37 MB) | the `mx create-project` in **every** reference build | +| `ref/` | published version UUID + Mendix version | the model only (~14 MB) | the whole reference, on a repeat | Measured on Administration (content 23513, 4.3.2 → 4.5.0, Mendix 11.12.1): @@ -217,20 +217,29 @@ Measured on Administration (content 23513, 4.3.2 → 4.5.0, Mendix 11.12.1): mxcli marketplace diff 23513 -p app.mpr --to 4.5.0 no cache 66s - cold cache 49s (blank app built once, reused by the second reference) - warm 13s + cold cache 47s (blank app built once, reused by the second reference) + warm 9s ``` +A `ref/` entry stores the `.mpr` (and `mprcontents/`) and nothing else. A +reference project is a whole blank Mendix app, but only its model is ever read — +`widgets/`, `themesource/`, `theme-cache/` and `javascriptsource/` account for +20 MB of 34 MB and are never opened, because an update takes the module's +bundled files from the `.mpk` rather than from the reference. Storing the model +alone made entries 58% smaller and warm runs faster, since less is copied. + The Mendix version is part of both keys, because a reference built at a different version reports Mendix's own conversions as local edits. An entry is only used once its completion marker is present and the project's version stamp has been re-checked on the way out, so a half-written or mislabelled entry is rebuilt rather than trusted. -A reference is about 34 MB, so `ref/` keeps the 6 most recently used entries and -evicts the rest — running out of disk part way through an update is worse than -rebuilding one, because `update` does not roll back. `blank/` is not bounded: it -holds one entry per Mendix version. +`ref/` keeps the 12 most recently used entries and evicts the rest — twelve is +what a six-module update sweep builds, so a whole sweep stays cached at ~170 MB. +It is bounded at all because running out of disk part way through an update is +worse than rebuilding an entry: `update` does not roll back, so a failed write +leaves the module already dropped. `blank/` is not bounded — one entry per +Mendix version. ```bash MXCLI_REF_CACHE_MAX=20 mxcli marketplace diff … # keep more (0 = keep everything) diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index a75fd6f3f..2599436f0 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -26,7 +26,7 @@ for display in this README): -## Active Proposals (88) +## Active Proposals (93) ### In Progress (partial) (11) @@ -45,7 +45,7 @@ for display in this README): | [Podman Support as Docker Alternative](PROPOSAL_podman_support.md) | Partial | Docker Desktop requires a paid subscription for larger organizations. | | [SHOW/DESCRIBE/USE Building Blocks](show-describe-building-blocks.md) | Partial | Document type: Pages$BuildingBlock (NOT Forms$BuildingBlock — the reader now | -### Proposed (36) +### Proposed (38) | Proposal | Status | Summary | |----------|--------|---------| @@ -56,6 +56,7 @@ for display in this README): | [Improving LLM Assistance for MDL Code Generation](PROPOSAL_llm_mdl_assistance.md) | Proposed | MDL (Mendix Definition Language) is a custom DSL that does not exist in LLM training data. | | [Large Source Files](refactor-large-files.md) | Proposed | This proposal addresses the refactoring of 6 large non-generated source files to improve maintainability and extensibility, especially for a | | [Mendix Automated Testing Pipeline](proposal-playwright-testing.md) | Proposed | Implementation Proposal for Claude Code | +| [mxcli developer panel — ask the agent about the interaction you just performed](proposal-browser-dev-panel.md) | Proposed | mxcli run --local produces four runtime signals — logs, metrics, traces, and the | | [mxcli Feature Request: Runtime Integration via M2EE Admin API](proposal-runtime-admin-port.md) | Proposed | During development with mxcli + Docker, we reverse-engineered the Mendix runtime's M2EE admin API | | [mxcli microflow debugger — breakpoints by name against a running runtime](PROPOSAL_microflow_debugger.md) | Proposed | Add first-class microflow-debugger support to mxcli: set breakpoints, inspect paused | | [mxcli tunnel-hub — GitHub authentication (hosted hub.mxcli.org)](PROPOSAL_hub_authentication.md) | Proposed | This proposal adds authentication to the multi-tenant tunnel-hub so a hosted | @@ -80,13 +81,14 @@ for display in this README): | [SHOW/DESCRIBE Rules](show-describe-rules.md) | Proposed | Document type: microflows$rule | | [SHOW/DESCRIBE Scheduled Events](show-describe-scheduled-events.md) | Proposed | Document type: ScheduledEvents$ScheduledEvent | | [SHOW/DESCRIBE Support for Missing Pluggable (React) Widgets](show-describe-pluggable-widgets.md) | Proposed | Scope: Improve DESCRIBE PAGE/SNIPPET output for pluggable widgets that currently fall through to generic formatting | +| [Solution-aware `mxcli init` — one dev container for a multi-project solution](proposal-solution-aware-init.md) | Proposed | A Mendix solution increasingly spans several projects — a frontend and a | | [Unified mxcli & MDL Documentation Site](proposal-documentation-site.md) | Proposed | mxcli has extensive documentation — 119 markdown files, a complete language specification, architecture docs, examples, and user guides — bu | | [Version-Aware MDL and BSON Serialization](version-aware-mdl.md) | Proposed | The Mendix metamodel evolves across versions. | | [VS Code MDL Visualizations](PROPOSAL_vscode_visualizations.md) | Proposed | Add visual diagram previews to the VS Code MDL extension, enabling users to see entity-relationship diagrams, microflow flowcharts, page wir | | [VS Code Search — Quick Pick + Workspace Symbol](PROPOSAL_vscode_search.md) | Proposed | Full-text search exists in mxcli (mxcli search) but is only accessible via the terminal. | | [Workflow Improvements: ALTER WORKFLOW + Cross-References](PROPOSAL_workflow_improvements.md) | Proposed | Workflow support in mxcli has full CREATE/DESCRIBE/DROP/SHOW coverage with 13 activity types and BSON round-trip fidelity. | -### Draft (40) +### Draft (41) | Proposal | Status | Summary | |----------|--------|---------| @@ -120,8 +122,9 @@ for display in this README): | [mxcli auth — Mendix Platform Authentication](PROPOSAL_platform_auth.md) | Draft | A growing set of mxcli features need to talk to Mendix platform APIs on behalf of the user: | | [mxcli catalog — Mendix Catalog Integration](PROPOSAL_catalog_integration.md) | Draft | ⚠️ TERMINOLOGY NOTE: This proposal covers the external Mendix Catalog service at catalog.mendix.com (CLI: mxcli catalog search), which is se | | [mxcli check — mine Mendix's diagnostics catalog to close the check↔mxbuild gap](PROPOSAL_check_diagnostics_catalog.md) | Draft | Relates to: PROPOSAL_check_mxbuild_gap_heuristics.md (one tactical slice of this — | -| [mxcli marketplace diff — detect local modification and plan an ID-preserving module upgrade](PROPOSAL_marketplace_module_upgrade.md) | Draft | Follows on from PROPOSAL_marketplace_modules.md, | +| [mxcli marketplace diff — detect local modification and plan a GUID-preserving module upgrade](PROPOSAL_marketplace_module_upgrade.md) | Draft | 2026-08-11 (DESCRIBE coverage measured — §7; GUID = database identity measured — §8) | | [mxcli Playground](mxcli-playground.md) | Draft | A public GitHub repository (mendixlabs/mxcli-playground) containing a ready-to-use Mendix project pre-configured with mxcli, Claude Code ski | +| [Owning the modelsdk/gen codegen — why the vendored generator cannot be adopted as-is](PROPOSAL_codegen_ownership.md) | Draft | Constraint set by the maintainer (2026-08-13): the reflection data is an | | [Playwright Session Reuse and Lifecycle Control](PROPOSAL_playwright_session_reuse.md) | Draft | Builds on proposal-playwright-cli.md, which | | [Project Brain — Persistent Knowledge and Session Scaffolding for Long-Term AI Collaboration](PROPOSAL_project_brain.md) | Draft | mxcli is developed with significant AI involvement, yet the project's knowledge infrastructure is built for static human reading rather than | | [RENAME with Reference Refactoring](PROPOSAL_rename_refactoring.md) | Draft | Renaming entities, microflows, pages, and modules is one of the most common refactoring operations. | diff --git a/docs/11-proposals/proposal-browser-dev-panel.md b/docs/11-proposals/proposal-browser-dev-panel.md new file mode 100644 index 000000000..75e38a158 --- /dev/null +++ b/docs/11-proposals/proposal-browser-dev-panel.md @@ -0,0 +1,331 @@ +--- +title: mxcli developer panel — ask the agent about the interaction you just performed +status: proposed +date: 2026-08-11 +author: Generated with Claude Code +related: + - PROPOSAL_mxcli_dev_warm_loop.md + - PROPOSAL_mcp_backend.md + - PROPOSAL_hub_authentication.md + - proposal-solution-aware-init.md +--- + +# Proposal: mxcli developer panel — ask the agent about the interaction you just performed + +**Status:** Proposed +**Date:** 2026-08-11 + +## Problem statement + +`mxcli run --local` produces four runtime signals — logs, metrics, traces, and the +model catalog — and [`analyze-runtime.md`](../../.claude/skills/mendix/analyze-runtime.md) +is the procedure for joining them. That skill works. What it cannot supply is the +**correlation key**: which of those spans belong to *the thing the user just did in +the browser*. + +Today that key exists only in the user's head, and reaches the agent as prose: + +> "I clicked Save on the customer form maybe a minute ago and it took ages — +> can you see what happened?" + +The agent then guesses at a time window, greps `runtime.log`, and hopes. The +information that would make the question precise — which widget, which page, which +XHR, at exactly which millisecond, producing exactly which span tree — was present +in the browser and thrown away. + +The proposal is a small panel inside the app under development that **captures that +context at the moment of the interaction** and makes it retrievable by the agent, so +"show me the flame chart caused by pressing this button" becomes an answerable +question rather than an approximate one. + +### Why now + +Three pieces landed that make this tractable, none of which existed when the warm +loop started: + +- `--trace-otlp` produces spans with real timestamps and parent IDs (the console + exporter omits both, so it cannot reconstruct a call tree at all). +- The tunnel-hub reverse-proxies every app response, giving a natural injection point + that requires nothing installed in the tester's browser. +- The hub has an authenticated owner identity (`Backend.Owner`), so a developer-only + surface can be gated on something real. + +## Design principle: the panel records, the agent pulls + +The obvious design is for the panel to send a prompt straight into the Claude Code +session and render the reply inline. **This proposal deliberately does not do that.** + +Pushing a prompt into a running session requires a "post a message to session X" +API. Such a surface may exist — `create_session` in the Claude Code Remote MCP +server references combining it with `send_message` and polling `list_events` — but +it is not exposed to an agent session today, and the in-agent `SendMessage` tool is +not something a web page can call. **It could not be verified while writing this +proposal, and it is outside mxcli's control.** + +So v1 inverts the flow: + +- The panel **records** an interaction into a store mxcli owns. +- The agent **pulls** it, on the user's ask, through an MCP tool. +- The answer appears **in the Claude Code conversation**, not in the panel. + +This loses the in-page reply, which is the most striking part of the pitch. It keeps +everything else, depends on nothing unverified, and leaves the push path as a +strictly additive follow-up (see Open questions). The user still asks in natural +language — they just ask in the place they are already working, and the panel's job +is to make "the interaction I just performed" a precise referent rather than a +description. + +## Architecture + +Three components, each independently useful: + +``` +Browser (app under test) Dev container +┌────────────────────────────┐ ┌──────────────────────────────────┐ +│ Mendix app │ │ Mendix runtime (JVM) │ +│ ┌──────────────────────┐ │ /xas/ ───▶ │ + OTel agent ──┐ │ +│ │ mxcli dev panel │ │ │ ▼ │ +│ │ · records clicks │ │ │ ┌───────────────────────────┐ │ +│ │ · captures XHRs │──┼── POST ─────▶│ │ mxcli run --local │ │ +│ │ · shows what it has │ │ /_mxcli/ │ │ · embedded OTLP collector│ │ +│ └──────────────────────┘ │ event │ │ · interaction store │ │ +└────────────────────────────┘ │ └─────────────┬─────────────┘ │ + ▲ │ │ MCP │ + │ injected by the hub │ ┌─────────────▼─────────────┐ │ + │ (ModifyResponse) │ │ Claude Code agent │ │ + └──────────────────────────────┼──│ get_interactions / trace │ │ + │ └───────────────────────────┘ │ + └──────────────────────────────────┘ +``` + +### 1. Embedded OTLP collector — `run --local --trace-collect` + +`--trace-otlp ` today points at a collector the user must run themselves. +`--trace-collect` instead starts an **in-process OTLP receiver** (http/protobuf on a +loopback port), points the runtime's agent at it, and keeps a bounded ring buffer of +spans in memory. + +This is the single highest-value piece and is **useful with no panel at all** — it +turns "spans go somewhere I have to set up" into "spans are queryable from mxcli", +which is what `analyze-runtime.md` currently sends people to Jaeger for. It should +ship first and alone. + +Bounded by span count and age (both flag-tunable), because the default filters still +let a busy transaction produce a lot of spans, and this buffer lives in the same +process as the dev loop. + +### 2. The panel + +A small self-contained script: a floating toggle, a list of recent interactions, and +a copyable reference for each. It records, per interaction: + +| Field | Source | +|-------|--------| +| Widget label / DOM path, page URL | click handler on `document`, capture phase | +| Timestamp window (start, end) | around the interaction's network activity | +| XHR requests fired | `fetch`/`XMLHttpRequest` wrapper, or `PerformanceObserver` | +| Mendix action name | the `/xas/` request payload | +| `traceparent` (if issued) | generated by the panel — see Correlation | + +It POSTs each to `/_mxcli/event` on the app's own origin, which mxcli serves +alongside the app. It does **not** talk to the agent, hold credentials, or render +agent output in v1. + +### 3. mxcli MCP server — `mxcli mcp serve` + +The agent-facing surface. Exposes the recorded interactions and the collector as MCP +tools: + +| Tool | Returns | +|------|---------| +| `list_interactions` | Recent interactions, newest first — label, page, time window, action | +| `get_interaction` | One interaction with its full captured detail | +| `get_trace` | The span tree for an interaction (or a raw time window), as a call tree with durations | +| `get_logs` | `runtime.log` lines within an interaction's window | + +Note the direction: [`PROPOSAL_mcp_backend.md`](PROPOSAL_mcp_backend.md) makes mxcli +an MCP **client** of Studio Pro's PED server. This is the **reverse** — mxcli as an +MCP *server* the agent connects to. They share no code and do not conflict, but the +naming should be kept clearly distinct (`mcp serve` vs the `--mcp` backend flag) or +the two will be confused permanently. + +## Correlation — the crux + +Tying a browser interaction to its server spans is the part that decides whether this +is precise or merely suggestive. Two mechanisms, and the proposal should implement +the fallback first because it always works. + +**Primary — W3C trace context.** The panel generates a `traceparent` per interaction +and attaches it to the outgoing XHR. If the runtime's OTel agent extracts inbound +trace context, every server span for that request becomes a child of the panel's +trace id, and correlation is exact. + +`analyze-runtime.md` records that *outbound* propagation works — "trace context (W3C +`traceparent`) crosses app boundaries automatically over `rest call`" — which shows +the agent's context plumbing is live, and standard OTel servlet instrumentation does +extract inbound headers by default. **This has not been verified against the Mendix +runtime, and nothing should be designed around it until it has.** It is also the +first thing to test, because if it works the rest of this section is unnecessary. + +Two caveats even when it works: Mendix may reject unexpected headers on `/xas/`, and +the panel adding a header changes the request the app sends — which must not alter +app behaviour. + +**Fallback — time window plus action name.** Record the interaction's start/end and +the `/xas/` action, then select spans overlapping that window on the matching service +name. Approximate: concurrent activity from another tester lands in the same window. +Good enough to answer "what did this button cost", not good enough to answer it on a +busy shared preview. The panel should say which mechanism produced a given answer +rather than presenting both as equally exact. + +## Panel injection — hub `ModifyResponse` + +The hub's app proxy (`cmd/mxcli/tunnelhub/server.go:135`) has a `Director` and no +`ModifyResponse` — it never touches response bodies today. Injection adds one: +rewrite `text/html` responses to include the panel's `