feat(studio): live step-progress in flow-run dialog
Some checks are pending
Security / Security check (push) Waiting to run

The flow-run dialog showed a single spinner + "running…" label
for the whole run. For multi-step flows the operator had no
way to see *which* step was busy or how close the run was to
finishing.

Replace the spinner with a live step list driven by a
StreamEvents subscription:

  ✔ extract       0.41s
   summarize
  ◻ notify
  ━━━━━━━━━━────  35%

Mirrors the `fai run` CLI rendering — one shared visual
language across both surfaces. Steps appear in execution
order as the hub emits step.started events; check + duration
on completion; cross + first-line error on failure; pause
icon on approval gates.

Implementation:

 - HubService.streamEvents(backfill, types) — new public
   stream-facade method that wraps HubClient.streamEvents and
   maps proto LoggedEvent → AuditEvent for the rest of Studio.
   Subscribed with backfill=0 so the dialog only sees events
   from this very run.

 - _FlowRunDialogState.initState subscribes BEFORE submitting
   the run, so the first step.started never gets lost in the
   gRPC handshake gap.

 - Two-layer filter on incoming events: same flow name AND
   timestamp >= dialog open time. The timestamp gate is what
   stops a previous run's tail-end from painting stale rows
   if the user re-runs the same saved flow.

 - _LiveStep + _LiveStepList — insertion-ordered map renders
   rows in runtime execution order (not alphabetical), so
   what the operator sees matches what the hub did.

Version 0.51.5 → 0.51.6.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-31 22:57:21 +02:00
parent 2b65cd771f
commit ba1a1a4f06
4 changed files with 249 additions and 15 deletions

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
@ -814,10 +815,33 @@ class _FlowRunDialog extends StatefulWidget {
class _FlowRunDialogState extends State<_FlowRunDialog> {
late final Future<Map<String, FlowOutput>> _future;
late final StreamSubscription<AuditEvent> _eventSub;
late final DateTime _startedAt;
// Insertion-ordered map so the rendered list mirrors the
// order steps were actually triggered, matching what the
// operator sees on the CLI side.
final Map<String, _LiveStep> _liveSteps = <String, _LiveStep>{};
@override
void initState() {
super.initState();
_startedAt = DateTime.now();
// Subscribe BEFORE submitting so the first `step.started`
// event lands in our handler rather than the broadcast
// channel's dead-letter buffer.
_eventSub = HubService.instance
.streamEvents(
backfill: 0,
types: const [
'step.started',
'step.completed',
'step.failed',
'step.awaiting_approval',
'step.approved',
'step.rejected',
],
)
.listen(_onEvent);
_future = HubService.instance.runSavedFlow(
name: widget.flow.name,
textInputs: widget.textInputs,
@ -826,6 +850,57 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
);
}
@override
void dispose() {
_eventSub.cancel();
super.dispose();
}
void _onEvent(AuditEvent e) {
// Two-layer filter: same flow name, AND only events newer
// than our subscribe-time. Without the timestamp gate,
// backfill or stream re-deliveries from an earlier run of
// the same flow would paint stale steps into this dialog.
if (e.flowName != widget.flow.name) return;
if (e.timestamp.isBefore(_startedAt)) return;
final stepId = e.stepId;
if (stepId == null || stepId.isEmpty) return;
setState(() {
switch (e.type) {
case 'step.started':
case 'step.approved':
_liveSteps[stepId] = _LiveStep(
id: stepId,
state: _StepState.running,
);
case 'step.completed':
_liveSteps[stepId] = _LiveStep(
id: stepId,
state: _StepState.done,
durationMs: e.durationMs,
);
case 'step.failed':
_liveSteps[stepId] = _LiveStep(
id: stepId,
state: _StepState.error,
error: e.error,
);
case 'step.awaiting_approval':
_liveSteps[stepId] = _LiveStep(
id: stepId,
state: _StepState.awaitingApproval,
);
case 'step.rejected':
_liveSteps[stepId] = _LiveStep(
id: stepId,
state: _StepState.error,
error: 'rejected',
);
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -857,19 +932,9 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: FaiSpace.md),
const CircularProgressIndicator(),
const SizedBox(height: FaiSpace.md),
Text(
l.flowsRunning,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
return _LiveStepList(
steps: _liveSteps.values.toList(growable: false),
runningLabel: l.flowsRunning,
);
}
if (snapshot.hasError) {
@ -947,6 +1012,144 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
}
}
enum _StepState { running, done, error, awaitingApproval }
class _LiveStep {
final String id;
final _StepState state;
final int? durationMs;
final String? error;
const _LiveStep({
required this.id,
required this.state,
this.durationMs,
this.error,
});
}
/// Live step list shown while a flow is running. Mirrors the
/// `fai run` CLI rendering: empty box for pending steps the
/// hub has hinted at but not started yet, a small spinner for
/// the currently-running step, a check + duration for done
/// steps, and a cross + first error line on failure. The list
/// grows in insertion order so the operator sees the runtime
/// order of execution, not a re-sorted view.
class _LiveStepList extends StatelessWidget {
final List<_LiveStep> steps;
final String runningLabel;
const _LiveStepList({
required this.steps,
required this.runningLabel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Empty state (subscribe arrived first, no step has fired
// yet) show a small spinner + the localised "running"
// label so the dialog doesn't look blank.
if (steps.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: FaiSpace.md),
const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: FaiSpace.md),
Text(
runningLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
final done = steps
.where((s) => s.state == _StepState.done || s.state == _StepState.error)
.length;
final pct = done * 100 ~/ steps.length;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final step in steps) _stepRow(context, step),
const SizedBox(height: FaiSpace.md),
LinearProgressIndicator(
value: pct / 100,
minHeight: 4,
backgroundColor: theme.colorScheme.surfaceContainerHigh,
),
const SizedBox(height: 4),
Text(
'$pct%',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Widget _stepRow(BuildContext context, _LiveStep step) {
final theme = Theme.of(context);
final Widget glyph;
final String suffix;
Color color = theme.colorScheme.onSurface;
switch (step.state) {
case _StepState.running:
glyph = const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
suffix = '';
case _StepState.done:
glyph = Icon(Icons.check, size: 16, color: theme.colorScheme.primary);
final s = (step.durationMs ?? 0) / 1000.0;
suffix = ' ${s.toStringAsFixed(2)}s';
case _StepState.error:
glyph = Icon(Icons.close, size: 16, color: theme.colorScheme.error);
suffix = step.error == null || step.error!.isEmpty
? ''
: '${step.error!.split('\n').first.trim()}';
color = theme.colorScheme.error;
case _StepState.awaitingApproval:
glyph = Icon(
Icons.pause_circle_outline,
size: 16,
color: theme.colorScheme.tertiary,
);
suffix = ' awaiting approval';
color = theme.colorScheme.tertiary;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(width: 16, child: Center(child: glyph)),
const SizedBox(width: FaiSpace.sm),
Flexible(
child: Text(
'${step.id}$suffix',
style: theme.textTheme.bodyMedium?.copyWith(color: color),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
/// Per-spec install state for the dependencies dialog. The
/// dialog walks the list sequentially, advancing each row from
/// `pending` `installing` `done` | `failed` so the operator