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
100
lib/src/model/auto_layout.dart
Normal file
100
lib/src/model/auto_layout.dart
Normal file
|
|
@ -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 = <int, List<FlowStep>>{};
|
||||||
|
for (final step in graph.steps) {
|
||||||
|
final col = cols[step.id] ?? 0;
|
||||||
|
byColumn.putIfAbsent(col, () => []).add(step);
|
||||||
|
}
|
||||||
|
final result = <String, NodePosition>{...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<String, int> _assignColumns(FlowGraph graph) {
|
||||||
|
final cols = <String, int>{};
|
||||||
|
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<String> _predecessors(FlowStep step, Set<String> stepIds) {
|
||||||
|
final refs = <String>{};
|
||||||
|
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_-]*)',
|
||||||
|
);
|
||||||
|
}
|
||||||
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});
|
||||||
|
}
|
||||||
144
lib/src/model/layout_store.dart
Normal file
144
lib/src/model/layout_store.dart
Normal file
|
|
@ -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/<flow-name>.json
|
||||||
|
//
|
||||||
|
// One file per flow. JSON shape:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "version": 1,
|
||||||
|
// "positions": {
|
||||||
|
// "<step-id>": { "x": 120.0, "y": 80.0 }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class FlowLayout {
|
||||||
|
final Map<String, NodePosition> positions;
|
||||||
|
const FlowLayout({this.positions = const {}});
|
||||||
|
|
||||||
|
factory FlowLayout.empty() => const FlowLayout();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'version': 1,
|
||||||
|
'positions': {
|
||||||
|
for (final entry in positions.entries)
|
||||||
|
entry.key: {'x': entry.value.x, 'y': entry.value.y},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
factory FlowLayout.fromJson(Map<String, dynamic> json) {
|
||||||
|
final raw = json['positions'];
|
||||||
|
if (raw is! Map) return FlowLayout.empty();
|
||||||
|
final positions = <String, NodePosition>{};
|
||||||
|
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<String, dynamic>) 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: fai_studio_flow_editor
|
name: fai_studio_flow_editor
|
||||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||||
version: 0.1.4
|
version: 0.2.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||||
|
|
||||||
|
|
@ -16,6 +16,10 @@ dependencies:
|
||||||
# WebView-free and ships in the same binary.
|
# WebView-free and ships in the same binary.
|
||||||
flutter_code_editor: ^0.3.5
|
flutter_code_editor: ^0.3.5
|
||||||
highlight: ^0.7.0
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
187
test/flow_graph_test.dart
Normal file
187
test/flow_graph_test.dart
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue