chain-studio-flow-editor/lib/src/model/flow_graph.dart
flemming-it dbd9a0004f 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>
2026-06-01 00:37:19 +02:00

436 lines
14 KiB
Dart

// 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<String, FlowInput> inputs;
/// Ordered list of steps. Order matters for the linear
/// engine; the graph view preserves it on save.
final List<FlowStep> steps;
/// Outputs the flow exposes — each value is an expression
/// that references a step output or an input
/// (`$step.field` / `$inputs.field`).
final Map<String, String> 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<FlowEdge> get edges {
final result = <FlowEdge>[];
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<String, FlowInput> _parseInputs(dynamic node) {
if (node is! Map) return const {};
final result = <String, FlowInput>{};
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<FlowStep> _parseSteps(dynamic node) {
if (node is! List) return const [];
final result = <FlowStep>[];
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_ = <String, dynamic>{};
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<String, String> _parseOutputs(dynamic node) {
if (node is! Map) return const {};
final result = <String, String>{};
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_or_inputs>.<field>
// 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<String, dynamic> 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<String, dynamic>? 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});
}