From ba1a1a4f064c7ff038f3c491ac3ebcd8c9f55139 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 31 May 2026 22:57:21 +0200 Subject: [PATCH] feat(studio): live step-progress in flow-run dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/hub.dart | 31 ++++++ lib/main.dart | 2 +- lib/pages/flows.dart | 229 ++++++++++++++++++++++++++++++++++++++++--- pubspec.yaml | 2 +- 4 files changed, 249 insertions(+), 15 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index c8aa65d..444cb2b 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -729,6 +729,37 @@ class HubService { }; } + /// Live audit-event stream. The hub's StreamEvents RPC + /// optionally replays [backfill] historical events first, then + /// keeps the channel open and forwards every new event as soon + /// as it lands. Filtering by [types] is applied on the hub + /// side so this surface stays lean even when the log is busy. + /// + /// Subscribe **before** triggering the work whose progress you + /// want to follow — otherwise the earliest `*.started` events + /// can arrive before the subscriber is ready and slip past. + Stream streamEvents({ + int backfill = 0, + List types = const [], + }) { + return _client.streamEvents(backfill: backfill, types: types).map( + (e) => AuditEvent( + eventId: e.eventId, + timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(), + type: e.eventType, + flowName: e.flowName.isEmpty ? null : e.flowName, + stepId: e.stepId.isEmpty ? null : e.stepId, + moduleName: e.moduleName.isEmpty ? null : e.moduleName, + moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion, + invocationId: e.invocationId.isEmpty ? null : e.invocationId, + flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution, + durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), + error: e.error.isEmpty ? null : e.error, + detail: e.detail.isEmpty ? null : e.detail, + ), + ); + } + Future> recentEvents({ int limit = 50, List types = const [], diff --git a/lib/main.dart b/lib/main.dart index 5069cd8..12c30a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.51.5'; +const String kStudioVersion = '0.51.6'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index a8b26ca..69c53c0 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -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> _future; + late final StreamSubscription _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 _liveSteps = {}; @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 diff --git a/pubspec.yaml b/pubspec.yaml index 6097045..0406ee2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.51.5 +version: 0.51.6 environment: sdk: ^3.11.0-200.1.beta