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,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();
}
}