fix(editor): per-field ports render from YAML refs when ModuleSpec is missing

Stefan reported: per-field output ports never appeared even
after the hub-side capability-lookup fix. Root cause: the
editor only used ModuleSpec when the host returned one. If
the operator's hub still ran the pre-fix binary, or hadn't
re-installed v3 manifests, or the step's capability was a
built-in (system.approval) that ships no manifest at all,
the editor would silently collapse to a single output anchor
even though the flow YAML clearly references named fields.

Robust fix: per-field labels are now a UNION of declared
fields (from the manifest, if any) and implicit ones
(inferred from the flow graph itself — every $step.field
reference an outgoing edge carries adds a field name).
Declared fields keep manifest order; implicit ones follow
alphabetically. Two helpers (_inputLabelsForStep,
_outputLabelsForStep) single-source the merge so the
renderer, port-overlay generator, edge router, and
hit-tester all see the same list.

What this means at run time:
- v3 modules with description.en/de → full per-field ports
  with tooltips, exactly as designed.
- v2 modules without descriptions → per-field ports, no
  tooltips. Was the case already.
- v1 modules / hubs without inputs/outputs in
  ModuleInfoResponse → per-field ports inferred from the
  YAML references. New behaviour, no regression.
- Built-in capabilities (system.approval) → per-field ports
  from with_-keys and outgoing edges. Works without any
  hub manifest.

Bumps fai_studio_flow_editor to 0.10.1.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 23:11:39 +02:00
parent a71f654162
commit 7797654572
2 changed files with 70 additions and 38 deletions

View file

@ -232,6 +232,42 @@ class _FlowCanvasState extends State<FlowCanvas>
return _moduleSpecs[cap];
}
/// Merge ModuleSpec-declared input names with whatever the
/// flow YAML's `with_:` block carries that the manifest
/// didn't declare. Declared inputs keep their manifest
/// order; implicit ones land alphabetically after. Same
/// list used by [_stepPositioned] for rendering and by
/// [_buildSegments] / [_hitTestEdge] for port positions.
List<String> _inputLabelsForStep(FlowStep step) {
final spec = _specForStep(step);
final declared = spec?.inputs.map((f) => f.name).toList() ?? const [];
final implicit =
step.with_.keys.where((k) => !declared.contains(k)).toList()..sort();
return [...declared, ...implicit];
}
/// Merge ModuleSpec-declared output names with whatever
/// the flow YAML's edges reference for this step. Declared
/// outputs keep their manifest order; implicit (referenced
/// but undeclared) outputs land alphabetically after. Lets
/// the editor render per-field output anchors even when the
/// hub hasn't shipped a manifest (built-ins like
/// system.approval, or older hub binaries).
List<String> _outputLabelsForStep(FlowStep step) {
final spec = _specForStep(step);
final declared = spec?.outputs.map((f) => f.name).toList() ?? const [];
final implicit = <String>{};
for (final edge in widget.controller.graph.edges) {
if (edge.fromKind == EdgeEndpointKind.step &&
edge.fromId == step.id &&
edge.fromField.isNotEmpty &&
!declared.contains(edge.fromField)) {
implicit.add(edge.fromField);
}
}
return [...declared, ...(implicit.toList()..sort())];
}
void _onControllerChanged() {
if (!mounted) return;
// Start the flow-animation controller only while a step
@ -669,17 +705,23 @@ class _FlowCanvasState extends State<FlowCanvas>
.whereType<Object>()
.where((v) => _isWiredExpression(v.toString()))
.length;
// Per-field input/output ports: prefer the resolved
// ModuleSpec from the hub when present, else fall back
// to the legacy YAML-derived shape (with_ keys on the
// left, single anchor on the right).
// Per-field input/output ports: merge whatever the
// ModuleSpec from the hub declares with whatever the
// flow YAML actually references. The union ensures we
// render ports even when the hub hasn't shipped a
// manifest (built-in capabilities like system.approval),
// when the hub binary is older than the per-field-port
// RPC, or when the operator hasn't re-installed the
// module to pick up its new schema_version 3 manifest.
//
// Declared fields keep their manifest order (alphabetical
// server-side). Implicit fields (those that show up in
// the YAML but aren't declared) are appended alphabetically
// afterwards so the layout stays stable when a new
// manifest later lands and adds them officially.
final spec = _specForStep(step);
final inputLabels = spec != null
? spec.inputs.map((f) => f.name).toList()
: step.with_.keys.toList();
final outputLabels = spec != null
? spec.outputs.map((f) => f.name).toList()
: const <String>[];
final inputLabels = _inputLabelsForStep(step);
final outputLabels = _outputLabelsForStep(step);
final tooltips = <String, String>{};
if (spec != null) {
final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en';
@ -806,11 +848,10 @@ class _FlowCanvasState extends State<FlowCanvas>
(s) => s.id == nodeId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final spec = _specForStep(step);
final outputs = spec?.outputs ?? const [];
final labels = _outputLabelsForStep(step);
double y;
if (fieldName != null && outputs.isNotEmpty) {
final idx = outputs.indexWhere((f) => f.name == fieldName);
if (fieldName != null && labels.isNotEmpty) {
final idx = labels.indexOf(fieldName);
if (idx >= 0) {
y = NodeGeometry.outputPortY(idx);
} else {
@ -876,15 +917,7 @@ class _FlowCanvasState extends State<FlowCanvas>
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
// Prefer the resolved ModuleSpec's declared input
// order when present (matches what the renderer
// draws); fall back to the with_ keys for legacy
// / unresolved modules.
final spec = _specForStep(step);
final inputLabels = spec != null
? spec.inputs.map((f) => f.name).toList()
: step.with_.keys.toList();
final idx = inputLabels.indexOf(edge.toField);
final idx = _inputLabelsForStep(step).indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout);
toSide = EdgeSide.left;
@ -1033,11 +1066,7 @@ class _FlowCanvasState extends State<FlowCanvas>
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final spec = _specForStep(step);
final inputLabels = spec != null
? spec.inputs.map((f) => f.name).toList()
: step.with_.keys.toList();
final idx = inputLabels.indexOf(edge.toField);
final idx = _inputLabelsForStep(step).indexOf(edge.toField);
if (idx >= 0) to = _inputPortPosition(edge.toId, idx, layout);
} else if (edge.toKind == EdgeEndpointKind.outputs) {
final outs = graph.outputs.keys.toList();
@ -1381,12 +1410,15 @@ class _FlowCanvasState extends State<FlowCanvas>
);
}
// Step input port targets left edges. Right-click on a
// wired port opens the disconnect menu.
// wired port opens the disconnect menu. We render every
// label the merged-labels helper emits (declared by the
// manifest + implicit from with_) so the dot positions
// match what FlowNode.build draws on the card body.
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final labels = _inputLabelsForStep(step);
for (var i = 0; i < labels.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final field = keys[i];
final field = labels[i];
final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value);
yield _portDot(
@ -1678,8 +1710,8 @@ class _FlowCanvasState extends State<FlowCanvas>
double bestDist = double.infinity;
Offset? best;
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final labels = _inputLabelsForStep(step);
for (var i = 0; i < labels.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
@ -1712,8 +1744,8 @@ class _FlowCanvasState extends State<FlowCanvas>
double bestDist = double.infinity;
const maxDist = 32.0;
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final labels = _inputLabelsForStep(step);
for (var i = 0; i < labels.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
@ -1721,7 +1753,7 @@ class _FlowCanvasState extends State<FlowCanvas>
best = _DropTarget(
kind: _DraftTargetKind.step,
id: step.id,
field: keys[i],
field: labels[i],
);
}
}

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.10.0
version: 0.10.1
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor