feat(model): FlowGraph + LayoutStore foundation for WYSIWYG editor

First piece of the v0.2.0 rewrite. The graph view, text view,
and run view will all share a single source of truth: the
YAML text. This commit introduces the parsing layer + the
sidecar that keeps layout metadata OUT of the YAML.

FlowGraph (lib/src/model/flow_graph.dart):
 - Parses + emits the F-Delta-I flow YAML shape (inputs +
   steps + outputs + leading comment block).
 - Recognises both inputs shorthand (`name: text`) and the
   expanded form (`name: { type: text, default: ..., hint: ... }`).
 - Detects edges by scanning step.with + outputs for
   $source.field references; understands both the short
   $x.y form (canonical) and the long ${{ x.y }} form (legacy).
 - Flags system.approval@ steps via FlowStep.isApproval so
   the graph can paint them with the human-gate styling.
 - Immutable update helpers (withStepUpdated, withStepAdded,
   withStepRemoved) used by the property panel + add-step
   button.
 - tryParse returns null on YAML syntax errors so the text
   tab can keep the last valid graph while the operator
   types.

LayoutStore + FlowLayout (lib/src/model/layout_store.dart):
 - Per-flow JSON sidecar at
   ~/.fai/data/flows/.layout/<name>.json. Atomic write via
   tmp + rename so a crash mid-save leaves the previous
   copy intact.
 - The YAML stays byte-identical to what `fai run` consumes
   — positions never bleed into the flow file.

AutoLayout (lib/src/model/auto_layout.dart):
 - Deterministic topological column layout for flows that
   have never been opened in the graph view before.
 - Steps with no $step refs sit in column 0; steps whose
   refs all point at column-0 steps sit in column 1; etc.
 - Cycles + dangling refs collapse to column 0 so nothing
   is ever invisible.

Tests (test/flow_graph_test.dart): 7 cases cover canonical
parsing, edge detection across both ref forms, approval-step
flagging, leading-comment round-trip, structural round-trip,
parser robustness on broken YAML, immutability invariant.
All pass.

Version 0.1.4 -> 0.2.0 (minor bump — significant new module
graph; public FlowEditorPage constructor API unchanged).

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 00:37:19 +02:00
parent daf6732893
commit dbd9a0004f
5 changed files with 872 additions and 1 deletions

View file

@ -0,0 +1,100 @@
// Auto-layout deterministic node placement when no sidecar
// positions exist yet. Pure function over the graph; called
// every time the graph view loads a flow that has no saved
// layout.
//
// Strategy: light-weight topological columns. Steps that
// can run first (no $step refs in their inputs) sit in
// column 0; steps whose only refs point at column-0 steps
// land in column 1; and so on. Rows within a column wrap
// vertically so wide graphs scroll horizontally and tall
// columns scroll vertically.
//
// This is intentionally simple operators are expected to
// fine-tune positions by dragging once a flow is open. The
// auto-layout exists to give a non-overlapping starting
// point, not the final visual.
import 'flow_graph.dart';
import 'layout_store.dart';
class AutoLayout {
// Box geometry in canvas pixels.
static const double nodeWidth = 220;
static const double nodeHeight = 110;
static const double hGap = 80;
static const double vGap = 40;
// Origin offset so the leftmost column doesn't hug the
// canvas edge leaves breathing room for the special
// "inputs" sidebar.
static const double originX = 320;
static const double originY = 80;
/// Produce positions for every step in [graph], filling in
/// any not already covered by [existing].
static FlowLayout layout(FlowGraph graph, FlowLayout existing) {
final cols = _assignColumns(graph);
final byColumn = <int, List<FlowStep>>{};
for (final step in graph.steps) {
final col = cols[step.id] ?? 0;
byColumn.putIfAbsent(col, () => []).add(step);
}
final result = <String, NodePosition>{...existing.positions};
byColumn.forEach((col, stepsInCol) {
for (var i = 0; i < stepsInCol.length; i++) {
final step = stepsInCol[i];
if (result.containsKey(step.id)) continue;
final x = originX + col * (nodeWidth + hGap);
final y = originY + i * (nodeHeight + vGap);
result[step.id] = NodePosition(x, y);
}
});
return FlowLayout(positions: result);
}
/// For each step, the 0-based column index it belongs to
/// (longest predecessor chain + 1). Steps with no
/// dependencies sit at column 0.
static Map<String, int> _assignColumns(FlowGraph graph) {
final cols = <String, int>{};
final stepIds = graph.steps.map((s) => s.id).toSet();
bool progressed;
do {
progressed = false;
for (final step in graph.steps) {
if (cols.containsKey(step.id)) continue;
final preds = _predecessors(step, stepIds);
if (preds.every(cols.containsKey)) {
final col = preds.isEmpty
? 0
: preds.map((p) => cols[p]!).reduce((a, b) => a > b ? a : b) + 1;
cols[step.id] = col;
progressed = true;
}
}
} while (progressed);
// Cycle / dangling-ref fallback anything still
// unassigned gets placed in column 0 so it's at least
// visible.
for (final step in graph.steps) {
cols.putIfAbsent(step.id, () => 0);
}
return cols;
}
static Set<String> _predecessors(FlowStep step, Set<String> stepIds) {
final refs = <String>{};
for (final v in step.with_.values) {
if (v is! String) continue;
for (final match in _refPattern.allMatches(v)) {
final src = match.group(1)!;
if (stepIds.contains(src)) refs.add(src);
}
}
return refs;
}
static final _refPattern = RegExp(
r'\$([A-Za-z_][A-Za-z0-9_-]*)\.([A-Za-z_][A-Za-z0-9_-]*)',
);
}

View file

@ -0,0 +1,436 @@
// FlowGraph in-memory model of a flow YAML.
//
// The YAML text is the canonical source of truth. The graph
// view derives a `FlowGraph` from the parsed YAML, and any
// edit in the graph view (drag a node, rename a step, add a
// connection) is re-emitted as YAML and pushed back into the
// shared text buffer. The graph never holds state the YAML
// can't represent — everything visible can be expressed in
// the YAML round-trip.
//
// Layout coordinates are the one piece that doesn't live in
// the YAML; those are stored in a sidecar file via
// [LayoutStore]. That keeps the YAML byte-identical to what
// `fai run` consumes.
import 'package:yaml/yaml.dart';
/// One flow, fully decoded from its YAML text.
class FlowGraph {
/// Flow display name (`name:` at the top of the YAML).
/// Optional many example flows omit it and the file name
/// serves as the identifier.
final String? name;
/// Declared inputs the operator supplies at run time.
/// Each entry maps the input name to its declared shape.
final Map<String, FlowInput> inputs;
/// Ordered list of steps. Order matters for the linear
/// engine; the graph view preserves it on save.
final List<FlowStep> steps;
/// Outputs the flow exposes each value is an expression
/// that references a step output or an input
/// (`$step.field` / `$inputs.field`).
final Map<String, String> outputs;
/// Free-form comment block at the top of the file. Captured
/// so the round-trip preserves human-authored context like
/// example invocations or compliance notes. We can only
/// preserve LEADING comments; mid-file comments are lost on
/// graph -> YAML emission (acceptable trade-off they're
/// rare and the operator gets a warning if any are present).
final String leadingComment;
const FlowGraph({
this.name,
required this.inputs,
required this.steps,
required this.outputs,
this.leadingComment = '',
});
/// Empty placeholder while a YAML buffer is still being
/// loaded or has just been cleared.
factory FlowGraph.empty() =>
const FlowGraph(inputs: {}, steps: [], outputs: {});
/// Parse [yamlText] into a [FlowGraph]. Returns `null` if
/// the YAML can't be parsed at all (syntax error). For
/// semantically invalid flows that still parse (missing
/// step.id, etc.) the result is best-effort and any
/// recovered structure is returned.
static FlowGraph? tryParse(String yamlText) {
try {
return FlowGraph.fromYaml(yamlText);
} catch (_) {
return null;
}
}
factory FlowGraph.fromYaml(String yamlText) {
final leading = _captureLeadingComments(yamlText);
final doc = loadYaml(yamlText);
if (doc is! Map) {
return FlowGraph(
inputs: const {},
steps: const [],
outputs: const {},
leadingComment: leading,
);
}
final name = doc['name']?.toString();
final inputs = _parseInputs(doc['inputs']);
final steps = _parseSteps(doc['steps']);
final outputs = _parseOutputs(doc['outputs']);
return FlowGraph(
name: name,
inputs: inputs,
steps: steps,
outputs: outputs,
leadingComment: leading,
);
}
/// Emit a YAML representation. The output is intentionally
/// stable and human-friendly: 2-space indent, double-quoted
/// strings when needed, no flow-style maps. Designed to
/// match the hand-authored examples shipped under
/// `flows/` so the text view doesn't shock the operator
/// after a graph edit.
String toYaml() {
final b = StringBuffer();
if (leadingComment.isNotEmpty) {
b.write(leadingComment);
if (!leadingComment.endsWith('\n')) b.writeln();
b.writeln();
}
if (name != null && name!.isNotEmpty) {
b.writeln('name: ${_scalar(name!)}');
b.writeln();
}
if (inputs.isNotEmpty) {
b.writeln('inputs:');
for (final entry in inputs.entries) {
final v = entry.value;
if (v.hasOnlyType) {
b.writeln(' ${entry.key}: ${v.type}');
} else {
b.writeln(' ${entry.key}:');
b.writeln(' type: ${v.type}');
if (v.defaultValue != null) {
b.writeln(' default: ${_scalar(v.defaultValue!)}');
}
if (v.hint != null) b.writeln(' hint: ${_scalar(v.hint!)}');
}
}
b.writeln();
}
if (steps.isNotEmpty) {
b.writeln('steps:');
for (final s in steps) {
b.writeln(' - id: ${s.id}');
b.writeln(' use: ${s.use}');
if (s.with_.isNotEmpty) {
b.writeln(' with:');
for (final entry in s.with_.entries) {
b.writeln(' ${entry.key}: ${_scalar(entry.value.toString())}');
}
}
}
b.writeln();
}
if (outputs.isNotEmpty) {
b.writeln('outputs:');
for (final entry in outputs.entries) {
b.writeln(' ${entry.key}: ${_scalar(entry.value)}');
}
}
return b.toString();
}
/// Derive the edges implied by `$step.field` /
/// `$inputs.field` references found in step `with:` values
/// and in outputs. The graph view paints one edge per
/// reference.
List<FlowEdge> get edges {
final result = <FlowEdge>[];
final stepIds = steps.map((s) => s.id).toSet();
for (final step in steps) {
for (final entry in step.with_.entries) {
final value = entry.value;
if (value is! String) continue;
for (final ref in _scanRefs(value)) {
if (ref.source == 'inputs' && inputs.containsKey(ref.field)) {
result.add(
FlowEdge(
fromKind: EdgeEndpointKind.inputs,
fromId: 'inputs',
fromField: ref.field,
toKind: EdgeEndpointKind.step,
toId: step.id,
toField: entry.key,
),
);
} else if (stepIds.contains(ref.source)) {
result.add(
FlowEdge(
fromKind: EdgeEndpointKind.step,
fromId: ref.source,
fromField: ref.field,
toKind: EdgeEndpointKind.step,
toId: step.id,
toField: entry.key,
),
);
}
}
}
}
for (final entry in outputs.entries) {
for (final ref in _scanRefs(entry.value)) {
if (ref.source == 'inputs' && inputs.containsKey(ref.field)) {
result.add(
FlowEdge(
fromKind: EdgeEndpointKind.inputs,
fromId: 'inputs',
fromField: ref.field,
toKind: EdgeEndpointKind.outputs,
toId: 'outputs',
toField: entry.key,
),
);
} else if (stepIds.contains(ref.source)) {
result.add(
FlowEdge(
fromKind: EdgeEndpointKind.step,
fromId: ref.source,
fromField: ref.field,
toKind: EdgeEndpointKind.outputs,
toId: 'outputs',
toField: entry.key,
),
);
}
}
}
return result;
}
/// Immutable update returns a new graph with one step
/// replaced. Used by the properties panel + drag handlers.
FlowGraph withStepUpdated(String stepId, FlowStep replacement) {
final updated = [
for (final s in steps)
if (s.id == stepId) replacement else s,
];
return FlowGraph(
name: name,
inputs: inputs,
steps: updated,
outputs: outputs,
leadingComment: leadingComment,
);
}
/// Append a fresh step at the end of the list.
FlowGraph withStepAdded(FlowStep step) {
return FlowGraph(
name: name,
inputs: inputs,
steps: [...steps, step],
outputs: outputs,
leadingComment: leadingComment,
);
}
/// Remove the step with [stepId]. Edges pointing to or
/// from it become dangling references the YAML still
/// emits them; the engine will fail at run time with a
/// clear "unknown ref" error. That's intentional: silently
/// rewriting downstream `with:` values would surprise the
/// operator.
FlowGraph withStepRemoved(String stepId) {
return FlowGraph(
name: name,
inputs: inputs,
steps: steps.where((s) => s.id != stepId).toList(),
outputs: outputs,
leadingComment: leadingComment,
);
}
// --- internals ---
static Map<String, FlowInput> _parseInputs(dynamic node) {
if (node is! Map) return const {};
final result = <String, FlowInput>{};
for (final entry in node.entries) {
final key = entry.key.toString();
final value = entry.value;
if (value is String) {
// shorthand: `name: text`
result[key] = FlowInput(type: value);
} else if (value is Map) {
result[key] = FlowInput(
type: value['type']?.toString() ?? 'text',
defaultValue: value['default']?.toString(),
hint: value['hint']?.toString(),
);
}
}
return result;
}
static List<FlowStep> _parseSteps(dynamic node) {
if (node is! List) return const [];
final result = <FlowStep>[];
for (final item in node) {
if (item is! Map) continue;
final id = item['id']?.toString();
final use = item['use']?.toString();
if (id == null || use == null) continue;
final with_ = <String, dynamic>{};
final withNode = item['with'];
if (withNode is Map) {
for (final w in withNode.entries) {
with_[w.key.toString()] = w.value;
}
}
result.add(FlowStep(id: id, use: use, with_: with_));
}
return result;
}
static Map<String, String> _parseOutputs(dynamic node) {
if (node is! Map) return const {};
final result = <String, String>{};
for (final entry in node.entries) {
result[entry.key.toString()] = entry.value.toString();
}
return result;
}
static String _captureLeadingComments(String text) {
final lines = text.split('\n');
final buf = StringBuffer();
for (final line in lines) {
final trimmed = line.trimLeft();
if (trimmed.isEmpty || trimmed.startsWith('#')) {
buf.writeln(line);
} else {
break;
}
}
return buf.toString();
}
/// Decide whether a value needs quoting in our emitter.
/// Plain alphanumerics + simple tokens stay unquoted; the
/// moment we see anything YAML could mis-interpret (colon,
/// dash at start, quotes, multi-line) we double-quote with
/// minimal escaping. Errs on the side of more quoting.
static String _scalar(String v) {
if (v.isEmpty) return '""';
if (v.contains('\n')) {
// Use literal block style for multi-line values; keeps
// the prompt-heavy flows readable.
final indented = v.split('\n').map((l) => ' $l').join('\n');
return '|-\n$indented';
}
final needsQuote =
v.contains(':') ||
v.contains('#') ||
v.startsWith('-') ||
v.startsWith('?') ||
v.startsWith('[') ||
v.startsWith('{') ||
v.startsWith('!') ||
v.startsWith('&') ||
v.startsWith('*') ||
v.contains('"') ||
v.trim() != v;
if (!needsQuote) return v;
final escaped = v.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
return '"$escaped"';
}
static Iterable<_Ref> _scanRefs(String value) sync* {
// FI expression syntax: $<step_or_inputs>.<field>
// Step IDs and field names match identifier rules:
// [A-Za-z_][A-Za-z0-9_-]*. The hub also accepts
// `${{ ... }}` but the canonical short form is what every
// shipped example uses, so the graph view only mints the
// short form on emit. Both are recognised on parse.
final shortPattern = RegExp(
r'\$([A-Za-z_][A-Za-z0-9_-]*)\.([A-Za-z_][A-Za-z0-9_-]*)',
);
for (final match in shortPattern.allMatches(value)) {
yield _Ref(source: match.group(1)!, field: match.group(2)!);
}
final longPattern = RegExp(
r'\$\{\{\s*([A-Za-z_][A-Za-z0-9_-]*)\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}',
);
for (final match in longPattern.allMatches(value)) {
yield _Ref(source: match.group(1)!, field: match.group(2)!);
}
}
}
class FlowInput {
final String type;
final String? defaultValue;
final String? hint;
const FlowInput({required this.type, this.defaultValue, this.hint});
bool get hasOnlyType => defaultValue == null && hint == null;
}
class FlowStep {
final String id;
final String use;
final Map<String, dynamic> with_;
const FlowStep({required this.id, required this.use, this.with_ = const {}});
/// True when the step is the built-in approval gate. The
/// graph view styles these differently so the operator can
/// see at a glance where the flow waits for a human.
bool get isApproval => use.startsWith('system.approval@');
FlowStep copyWith({String? id, String? use, Map<String, dynamic>? with_}) {
return FlowStep(
id: id ?? this.id,
use: use ?? this.use,
with_: with_ ?? this.with_,
);
}
}
enum EdgeEndpointKind { inputs, step, outputs }
/// One arrow on the graph. Identifies which step/field it
/// leaves from and which step/field it enters.
class FlowEdge {
final EdgeEndpointKind fromKind;
final String fromId;
final String fromField;
final EdgeEndpointKind toKind;
final String toId;
final String toField;
const FlowEdge({
required this.fromKind,
required this.fromId,
required this.fromField,
required this.toKind,
required this.toId,
required this.toField,
});
}
class _Ref {
final String source;
final String field;
const _Ref({required this.source, required this.field});
}

View file

@ -0,0 +1,144 @@
// Layout sidecar node positions persisted alongside a flow.
//
// Position metadata is intentionally kept OUT of the flow
// YAML. Reasons:
//
// 1. The YAML is what `fai run` consumes, what gets
// tamper-checked against the audit log, and what shows
// up in pull requests. It should describe behaviour
// only, not where a node happened to be on a screen.
//
// 2. Two operators viewing the same flow may legitimately
// want different layouts. The sidecar is per-user (and
// can be re-laid-out fresh from any host).
//
// 3. If the sidecar is missing or corrupt, the graph view
// falls back to a deterministic auto-layout. The flow
// itself never breaks.
//
// Storage layout:
//
// ~/.fai/data/flows/.layout/<flow-name>.json
//
// One file per flow. JSON shape:
//
// {
// "version": 1,
// "positions": {
// "<step-id>": { "x": 120.0, "y": 80.0 }
// }
// }
import 'dart:convert';
import 'dart:io';
class FlowLayout {
final Map<String, NodePosition> positions;
const FlowLayout({this.positions = const {}});
factory FlowLayout.empty() => const FlowLayout();
Map<String, dynamic> toJson() => {
'version': 1,
'positions': {
for (final entry in positions.entries)
entry.key: {'x': entry.value.x, 'y': entry.value.y},
},
};
factory FlowLayout.fromJson(Map<String, dynamic> json) {
final raw = json['positions'];
if (raw is! Map) return FlowLayout.empty();
final positions = <String, NodePosition>{};
for (final entry in raw.entries) {
final v = entry.value;
if (v is! Map) continue;
final x = (v['x'] as num?)?.toDouble();
final y = (v['y'] as num?)?.toDouble();
if (x == null || y == null) continue;
positions[entry.key.toString()] = NodePosition(x, y);
}
return FlowLayout(positions: positions);
}
FlowLayout withPosition(String stepId, NodePosition pos) {
return FlowLayout(positions: {...positions, stepId: pos});
}
FlowLayout withoutStep(String stepId) {
return FlowLayout(
positions: {
for (final entry in positions.entries)
if (entry.key != stepId) entry.key: entry.value,
},
);
}
}
class NodePosition {
final double x;
final double y;
const NodePosition(this.x, this.y);
@override
bool operator ==(Object other) =>
other is NodePosition && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
/// On-disk persistence for [FlowLayout]. Synchronous I/O is
/// fine these files are tiny (a few KB at most) and the
/// graph view loads / saves them at well-defined moments
/// (open file, drag end), not in the render loop.
class LayoutStore {
/// Directory `~/.fai/data/flows/.layout/`, created on first
/// write. Hidden so it doesn't show up in `fai admin
/// flows list` style enumerations.
static String defaultDir() {
final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'.';
return '$home/.fai/data/flows/.layout';
}
/// Load the layout for [flowName]. Returns an empty layout
/// if the sidecar doesn't exist yet (first time the flow is
/// opened in the graph view), or if it fails to parse.
static FlowLayout loadSync(String flowName) {
try {
final file = File('${defaultDir()}/$flowName.json');
if (!file.existsSync()) return FlowLayout.empty();
final raw = file.readAsStringSync();
final json = jsonDecode(raw);
if (json is! Map<String, dynamic>) return FlowLayout.empty();
return FlowLayout.fromJson(json);
} catch (_) {
return FlowLayout.empty();
}
}
/// Persist [layout] for [flowName]. Atomic via write-rename
/// so a crash mid-write leaves the previous good copy
/// intact.
static void saveSync(String flowName, FlowLayout layout) {
final dir = Directory(defaultDir());
if (!dir.existsSync()) dir.createSync(recursive: true);
final final_ = File('${dir.path}/$flowName.json');
final tmp = File('${dir.path}/$flowName.json.tmp');
tmp.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(layout.toJson()),
flush: true,
);
tmp.renameSync(final_.path);
}
/// Remove the sidecar call this when the operator
/// deletes a flow so we don't leak stale layouts.
static void deleteSync(String flowName) {
final file = File('${defaultDir()}/$flowName.json');
if (file.existsSync()) file.deleteSync();
}
}