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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/backend", "date": "2026-10-21", "symptom": "A microflow created over --mcp (Studio Pro PED, 11.15.0-rc.4) read back with every sequence flow 1 -> 3: a CASE built with `@anchor(from: bottom, Approved: (from: right), (empty): (from: bottom, to: top))` lost all three anchors and the merge's bottom exit. A script with any `@annotation` failed the create outright: `microflow object type *microflows.Annotation is not yet supported by the MCP backend`.", "cause": "buildFlowDocContent mapped a SequenceFlow to originId/destinationId/caseValue only, so Studio Pro applied its right -> left default; the splice (flowPatchOps) already sent originConnectionSide/destinationConnectionSide. mapObjectTree had no Annotation case and ObjectCollection.AnnotationFlows was never read. The splice's note line used originId/destinationId, the SequenceFlow constructor's keys. PED's AnnotationFlow is an element type that takes origin/destination plus the required originConnectionIndex/destinationConnectionIndex, and refuses the constructor keys with `\"/origin\":\"Expected reference (string), got undefined\"`. That bug was hidden because the note was refused before its line was mapped.", "file": "mdl/backend/mcp/microflow.go (buildFlowDocContent, mapObjectTree, setPEDSides, pedAnnotationFlow, adaptMicroflowSkeleton, CreateMicroflow), mdl/backend/mcp/microflow_mutator.go (flowPatchOps), sdk/microflows/microflows.go (AnnotationFlow.SidesOrDefault)", "insight": "Compare engines, not shapes: run the same script through the modelsdk engine and over --mcp, then diff each flow's (origin, destination, sides) after resolving PED's $id(/objectCollection/objects/N) references to positions. That diff covered all 10 flows (identical after the fix, every one 1 -> 3 before) and settled the bug faster than reading either writer. Read ped_get_schema per element type: 'constructor type' and 'element type' differ in key names (originId vs origin) for what looks like the same flow list. PED's read-back omits a property at its default, so a missing connection index means 0 (Top), not absent. The pre-11.14 constructor cannot type a flow, so notes are refused there: an untyped note line would be read as a SequenceFlow. Within one update PED stores an AnnotationFlow after a SequenceFlow added later, so the splice adds its note lines last; otherwise its index-addressed sets on the new sequence flow would point at the wrong flow. Still open: ALTER insert over MCP on 11.15 fails whenever it sets line/<end>ControlVector on a flow added in the same update (`undefined is not an object (evaluating 'u.$Type')`), with or without a note.", "refs": ["ako/mxcli#1528"]}
35 changes: 35 additions & 0 deletions docs/03-development/PED_MCP_CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,41 @@ behaviour change, not an error.
read the `page-gen-common` skill first); page create and ALTER ran clean on both
servers.

### Microflow canvas: flow sides and notes (measured live 2026-10-21, 11.15.0-rc.4)

- **A flow created without sides is drawn right → left.** The `SequenceFlow`
constructor takes `originConnectionSide` / `destinationConnectionSide`
(`Top | Right | Bottom | Left`). The create path sent neither, so every `@anchor`,
CASE outcome anchor and builder default was lost: a CASE built with
`@anchor(from: bottom, Approved: (from: right), (empty): (from: bottom, to: top))`
read back `1 -> 3` on all eight flows. **Fixed:** `buildFlowDocContent` maps the
connection indexes the way the splice already did (`setPEDSides`).
- **A note is a `Microflows$Annotation` object** (`x`, `y`, `caption`, `size`) and
its line a **`Microflows$AnnotationFlow` element, not a constructor.** Its ends
are `origin` / `destination`, not `originId` / `destinationId`, and its sides are
the required `originConnectionIndex` / `destinationConnectionIndex` themselves.
The constructor keys are refused: `"/origin":"Expected reference (string), got
undefined"`. Before the fix, a microflow with a note failed the whole create
(`mapObjectTree` had no case for it). The splice had the same wrong keys too,
hidden because the note was refused before its line was mapped.
- Verified: the same script read back from Studio Pro and from the modelsdk engine
gives **identical sides on all 10 flows**, both note lines included (an unplaced
note `2 -> 0`, a note placed below its activity `0 -> 2`). It also gives the same
note positions, sizes and captions, and `ped_check_errors` is clean on two
successive calls. A read-back leaves out a connection index of 0, its default.
- The pre-11.14 (full) constructor's flows carry no `$Type`, so a note's line could
only be read as a `SequenceFlow`. The backend refuses notes there rather than
risk an execution edge out of a note.
- Within one `ped_update_document`, an `AnnotationFlow` added *before* a
`SequenceFlow` is stored *after* it. So the splice adds its new sequence flows
(and the sets that address them by index) first, and note lines last.
- **Open: ALTER MICROFLOW insert over MCP fails on 11.15.** Setting
`/flows/N/line/originControlVector` on a flow added in the same update fails the
whole update with `undefined is not an object (evaluating 'u.$Type')`. This
happened without any note: a plain one-activity insert failed, and so did a lone
`SequenceFlow` add followed by that set. Setting the flow's whole `line` instead
is accepted but **not applied**: the read-back has no control vectors.

## Capability gaps (established 11.11, status re-checked each release)

These are the *absences* that bound what the backend can do. They are as
Expand Down
43 changes: 43 additions & 0 deletions mdl-examples/bug-tests/mcp-create-flow-sides-and-notes.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
mdl 1;
-- ============================================================================
-- A microflow created over --mcp keeps its flow sides and its notes
-- ============================================================================
--
-- Run it twice: once with the default engine, once against Studio Pro's MCP
-- server (`--mcp http://localhost/mcp --mcp-dial <host>:<port>`). Then compare
-- each flow's connection sides (the default engine's .mpr via
-- `mxcli bson dump`, Studio Pro's document via ped_read_document). They must
-- be the same.
--
-- Before the fix, the MCP create sent no sides, so Studio Pro drew every flow
-- right -> left (1 -> 3): the three outcome anchors and the merge's bottom exit
-- were lost. Any @annotation failed the create outright. Measured on
-- 11.15.0-rc.4: after the fix, all 10 flows match the default engine, both
-- note lines included (2 -> 0 for the note above, 0 -> 2 for the one placed
-- below).
--
-- Studio Pro's MCP server has no delete, so the documents stay in the open
-- project after a run.
-- ============================================================================

CREATE MODULE BugMcpSides;

CREATE OR MODIFY ENUMERATION BugMcpSides.Verdict (
Approved 'Approved',
Rejected 'Rejected'
);

CREATE MICROFLOW BugMcpSides.MCPS_CaseNotes ($T: BugMcpSides.Verdict)
BEGIN
@anchor(from: bottom, Approved: (from: right), (empty): (from: bottom, to: top))
CASE $T
WHEN Approved THEN
@annotation 'Approved takes this branch'
LOG INFO NODE 'MCPS' 'approved';
WHEN Rejected THEN
@annotation(text: 'Below the rejected log', position: (600, 420))
LOG INFO NODE 'MCPS' 'rejected';
WHEN (empty) THEN
LOG WARNING NODE 'MCPS' 'no verdict';
END CASE;
END;
83 changes: 80 additions & 3 deletions mdl/backend/mcp/microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ func (b *Backend) CreateMicroflow(mf *microflows.Microflow) error {
var ops []pedOpEntry
if b.microflowConstructorTakesSkeleton() {
ops = adaptMicroflowSkeleton(content)
} else if containsNote(content) {
// The full constructor's flows carry no $Type, so a note's line could
// only be read as a SequenceFlow — an execution edge out of the note.
return fmt.Errorf("microflow %q has a note, which Studio Pro's MCP API takes only from the microflow constructor 11.14 introduced; this Studio Pro has the older one — remove the note or run without --mcp", mf.Name)
}
if err := b.ensureSchema(microflowDocType); err != nil {
return err
Expand Down Expand Up @@ -104,7 +108,8 @@ func (b *Backend) microflowConstructorTakesSkeleton() bool {
// - action, returnValue, the split condition and the loop source become `set`
// operations on the stored document (/objectCollection/objects/N/…, nesting
// through a loop's objectCollection);
// - each flow gets its $Type, the return type flattens to an enum plus
// - each sequence flow gets its $Type (a note's line already has one), the
// return type flattens to an enum plus
// returnTypeEntity/returnTypeEnumeration, and returnVariableName becomes an
// operation.
func adaptMicroflowSkeleton(content map[string]any) []pedOpEntry {
Expand Down Expand Up @@ -140,8 +145,10 @@ func adaptMicroflowSkeleton(content map[string]any) []pedOpEntry {
if !ok {
continue
}
fm["$Type"] = "Microflows$SequenceFlow"
for _, k := range []string{"originId", "destinationId"} {
if _, typed := fm["$Type"]; !typed {
fm["$Type"] = "Microflows$SequenceFlow"
}
for _, k := range []string{"originId", "destinationId", "origin", "destination"} {
if ref, ok := fm[k].(string); ok {
fm[k] = shiftObjectRef(ref, len(params))
}
Expand Down Expand Up @@ -303,6 +310,7 @@ func (b *Backend) buildFlowDocContent(kind, name string, params []*microflows.Mi
"originId": fmt.Sprintf("$id(%s)", op),
"destinationId": fmt.Sprintf("$id(%s)", dp),
}
setPEDSides(pf, f)
cv, err := mapCaseValue(f.CaseValue)
if err != nil {
return nil, fmt.Errorf("%s %q: %w", kind, name, err)
Expand All @@ -312,6 +320,16 @@ func (b *Backend) buildFlowDocContent(kind, name string, params []*microflows.Mi
}
flows = append(flows, pf)
}
// A note's line shares the flows list, after the sequence flows, as the
// stored document has it.
for _, af := range oc.AnnotationFlows {
op, ok1 := idPath[af.OriginID]
dp, ok2 := idPath[af.DestinationID]
if !ok1 || !ok2 {
return nil, fmt.Errorf("%s %q: a note is attached to an object that is not supported yet", kind, name)
}
flows = append(flows, pedAnnotationFlow(af, fmt.Sprintf("$id(%s)", op), fmt.Sprintf("$id(%s)", dp)))
}
}

returnTypeName, rtEntity, rtEnum, err := mfDataType(returnType)
Expand Down Expand Up @@ -416,6 +434,55 @@ func pedMiddlePoint(pt model.Point) map[string]int {
return map[string]int{"x": pt.X, "y": pt.Y}
}

var pedSides = []string{"Top", "Right", "Bottom", "Left"}

// setPEDSides names a sequence flow's connection sides on its PED constructor.
// Left out, Studio Pro draws every flow right -> left (measured on
// 11.15.0-rc.4), whatever the builder or @anchor chose.
func setPEDSides(v map[string]any, f *microflows.SequenceFlow) {
if f.OriginConnectionIndex >= 0 && f.OriginConnectionIndex < 4 {
v["originConnectionSide"] = pedSides[f.OriginConnectionIndex]
}
if f.DestinationConnectionIndex >= 0 && f.DestinationConnectionIndex < 4 {
v["destinationConnectionSide"] = pedSides[f.DestinationConnectionIndex]
}
}

// pedAnnotationFlow maps a note's line onto PED's Microflows$AnnotationFlow.
// Unlike a SequenceFlow it is an element, not a constructor: its ends are
// origin/destination (not originId/destinationId) and its sides are the
// required connection indexes themselves.
func pedAnnotationFlow(af *microflows.AnnotationFlow, origin, destination string) map[string]any {
sides := af.SidesOrDefault()
return map[string]any{
"$Type": "Microflows$AnnotationFlow",
"origin": origin,
"destination": destination,
"originConnectionIndex": sides.Origin,
"destinationConnectionIndex": sides.Destination,
}
}

// containsNote reports whether mapped content has a note anywhere on its
// canvas, loop bodies included.
func containsNote(content map[string]any) bool {
var walk func(objs []any) bool
walk = func(objs []any) bool {
for _, o := range objs {
m, _ := o.(map[string]any)
if m["$Type"] == "Microflows$Annotation" {
return true
}
if body, ok := m["objects"].([]any); ok && walk(body) {
return true
}
}
return false
}
objects, _ := content["objects"].([]any)
return walk(objects)
}

// mapObjectTree maps one executor microflow object onto a PED /objects entry,
// registering its path in idPath and recursing into loop bodies (whose objects
// nest at <path>/objects/M). Positions come from the executor's layout engine.
Expand Down Expand Up @@ -520,6 +587,16 @@ func (b *Backend) mapObjectTree(o microflows.MicroflowObject, path string, idPat
}
m["objects"] = body
return m, nil
case *microflows.Annotation:
m := map[string]any{
"$Type": "Microflows$Annotation",
"relativeMiddlePoint": pos,
"caption": obj.Caption,
}
if obj.Size.Width > 0 && obj.Size.Height > 0 {
m["size"] = map[string]int{"width": obj.Size.Width, "height": obj.Size.Height}
}
return m, nil
default:
return nil, fmt.Errorf("microflow object type %T is not yet supported by the MCP backend", o)
}
Expand Down
Loading
Loading