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>
144 lines
4.4 KiB
Dart
144 lines
4.4 KiB
Dart
// 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();
|
|
}
|
|
}
|