// 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/.json // // One file per flow. JSON shape: // // { // "version": 1, // "positions": { // "": { "x": 120.0, "y": 80.0 } // } // } import 'dart:convert'; import 'dart:io'; class FlowLayout { final Map positions; const FlowLayout({this.positions = const {}}); factory FlowLayout.empty() => const FlowLayout(); Map toJson() => { 'version': 1, 'positions': { for (final entry in positions.entries) entry.key: {'x': entry.value.x, 'y': entry.value.y}, }, }; factory FlowLayout.fromJson(Map json) { final raw = json['positions']; if (raw is! Map) return FlowLayout.empty(); final positions = {}; 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) 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(); } }