diff --git a/lib/src/model/auto_layout.dart b/lib/src/model/auto_layout.dart new file mode 100644 index 0000000..2f2dba0 --- /dev/null +++ b/lib/src/model/auto_layout.dart @@ -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 = >{}; + for (final step in graph.steps) { + final col = cols[step.id] ?? 0; + byColumn.putIfAbsent(col, () => []).add(step); + } + final result = {...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 _assignColumns(FlowGraph graph) { + final cols = {}; + 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 _predecessors(FlowStep step, Set stepIds) { + final refs = {}; + 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_-]*)', + ); +} diff --git a/lib/src/model/flow_graph.dart b/lib/src/model/flow_graph.dart new file mode 100644 index 0000000..5c013b1 --- /dev/null +++ b/lib/src/model/flow_graph.dart @@ -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 inputs; + + /// Ordered list of steps. Order matters for the linear + /// engine; the graph view preserves it on save. + final List steps; + + /// Outputs the flow exposes — each value is an expression + /// that references a step output or an input + /// (`$step.field` / `$inputs.field`). + final Map 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 get edges { + final result = []; + 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 _parseInputs(dynamic node) { + if (node is! Map) return const {}; + final result = {}; + 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 _parseSteps(dynamic node) { + if (node is! List) return const []; + final result = []; + 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_ = {}; + 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 _parseOutputs(dynamic node) { + if (node is! Map) return const {}; + final result = {}; + 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* { + // F∆I expression syntax: $. + // 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 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? 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}); +} diff --git a/lib/src/model/layout_store.dart b/lib/src/model/layout_store.dart new file mode 100644 index 0000000..a9067e5 --- /dev/null +++ b/lib/src/model/layout_store.dart @@ -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/.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(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fc42da8..6ff8759 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.1.4 +version: 0.2.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor @@ -16,6 +16,10 @@ dependencies: # WebView-free and ships in the same binary. flutter_code_editor: ^0.3.5 highlight: ^0.7.0 + # YAML parsing for the graph view's model layer. We + # round-trip text <-> graph via this; the graph never + # mutates the YAML directly. + yaml: ^3.1.2 dev_dependencies: flutter_test: diff --git a/test/flow_graph_test.dart b/test/flow_graph_test.dart new file mode 100644 index 0000000..1c8a638 --- /dev/null +++ b/test/flow_graph_test.dart @@ -0,0 +1,187 @@ +// Round-trip + edge-detection tests for FlowGraph. The graph +// model is the foundation under the WYSIWYG editor — a bug +// here corrupts every flow the operator opens, so the parser +// gets full coverage of the shipped example shapes. + +import 'package:fai_studio_flow_editor/src/model/flow_graph.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FlowGraph parser', () { + test('parses the canonical hello flow', () { + const yaml = ''' +name: hello + +inputs: + name: text + +steps: + - id: greet + use: debug.echo@^0 + with: + message: "Hello, \$inputs.name" + +outputs: + greeting: \$greet.echoed +'''; + final graph = FlowGraph.fromYaml(yaml); + expect(graph.name, 'hello'); + expect(graph.inputs.keys, contains('name')); + expect(graph.inputs['name']!.type, 'text'); + expect(graph.steps, hasLength(1)); + expect(graph.steps.first.id, 'greet'); + expect(graph.steps.first.use, 'debug.echo@^0'); + expect(graph.outputs['greeting'], '\$greet.echoed'); + }); + + test('detects step-to-step + input-to-step edges', () { + const yaml = ''' +inputs: + doc: bytes + +steps: + - id: extract + use: text.extract@^0 + with: + document: \$inputs.doc + - id: sum + use: text.summarize@^0 + with: + text: \$extract.extracted + +outputs: + result: \$sum.summary +'''; + final graph = FlowGraph.fromYaml(yaml); + final edges = graph.edges; + expect(edges, hasLength(3)); + // inputs.doc -> extract.document + expect( + edges.any( + (e) => + e.fromKind == EdgeEndpointKind.inputs && + e.fromField == 'doc' && + e.toId == 'extract' && + e.toField == 'document', + ), + isTrue, + ); + // extract.extracted -> sum.text + expect( + edges.any( + (e) => + e.fromKind == EdgeEndpointKind.step && + e.fromId == 'extract' && + e.toId == 'sum' && + e.toField == 'text', + ), + isTrue, + ); + // sum.summary -> outputs.result + expect( + edges.any( + (e) => + e.fromKind == EdgeEndpointKind.step && + e.fromId == 'sum' && + e.toKind == EdgeEndpointKind.outputs && + e.toField == 'result', + ), + isTrue, + ); + }); + + test('flags system.approval steps via isApproval', () { + const yaml = ''' +steps: + - id: review + use: system.approval@^0 + with: + prompt: "Approve?" +'''; + final graph = FlowGraph.fromYaml(yaml); + expect(graph.steps.single.isApproval, isTrue); + }); + + test('captures leading comment block for round-trip', () { + const yaml = ''' +# Top-of-file comment. +# Spans multiple lines. + +name: x +inputs: + a: text +steps: [] +outputs: {} +'''; + final graph = FlowGraph.fromYaml(yaml); + expect(graph.leadingComment, contains('Top-of-file comment.')); + final round = graph.toYaml(); + expect(round, contains('Top-of-file comment.')); + }); + + test('round-trips a multi-step flow byte-stably for the shape', () { + const yaml = ''' +name: round-trip + +inputs: + a: text + b: bytes + +steps: + - id: one + use: text.extract@^0 + with: + document: \$inputs.b + - id: two + use: llm.chat@^0 + with: + prompt: \$one.extracted + +outputs: + result: \$two.response +'''; + final first = FlowGraph.fromYaml(yaml); + final emitted = first.toYaml(); + final second = FlowGraph.fromYaml(emitted); + // Structural equality — names, IDs, edges + expect(second.name, first.name); + expect(second.inputs.keys, first.inputs.keys); + expect(second.steps.map((s) => s.id), first.steps.map((s) => s.id)); + expect(second.steps.map((s) => s.use), first.steps.map((s) => s.use)); + expect(second.outputs, first.outputs); + // Edge set identical + final firstEdges = first.edges + .map((e) => '${e.fromId}.${e.fromField}>${e.toId}.${e.toField}') + .toSet(); + final secondEdges = second.edges + .map((e) => '${e.fromId}.${e.fromField}>${e.toId}.${e.toField}') + .toSet(); + expect(secondEdges, firstEdges); + }); + + test('tolerates broken YAML — tryParse returns null without throwing', () { + const bad = ''' +inputs: { + this is not yaml +'''; + expect(FlowGraph.tryParse(bad), isNull); + }); + + test('immutable updates produce independent graphs', () { + const yaml = ''' +steps: + - id: a + use: debug.echo@^0 + - id: b + use: debug.echo@^0 +'''; + final graph = FlowGraph.fromYaml(yaml); + final updated = graph.withStepUpdated( + 'a', + FlowStep(id: 'a', use: 'debug.echo@^1'), + ); + expect(graph.steps.first.use, 'debug.echo@^0'); + expect(updated.steps.first.use, 'debug.echo@^1'); + }); + }); +}