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>
436 lines
14 KiB
Dart
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});
|
|
}
|