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>
This commit is contained in:
parent
daf6732893
commit
dbd9a0004f
5 changed files with 872 additions and 1 deletions
436
lib/src/model/flow_graph.dart
Normal file
436
lib/src/model/flow_graph.dart
Normal file
|
|
@ -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<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});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue