// 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}); }