fix(runs): stop claiming the hub is unreachable on RPC-level errors

The runs monitor folded every load failure into 'hub not reachable',
contradicting the sidebar's green connected dot whenever the hub
answered but the RPC failed — most visibly against a pre-0.22 hub
whose version predates the ListInvocations RPC (UNIMPLEMENTED).

Classify the failure instead (top-level, unit-tested):
- UNIMPLEMENTED -> 'this view needs a newer hub version' with a
  doctor-page link (the update banner lives there)
- FAILED_PRECONDITION from the detached gate -> the regular
  feature-off empty state with the guide button
- UNAVAILABLE / DEADLINE_EXCEEDED / socket-level failures -> the
  honest 'hub not reachable' state (unchanged)
- everything else -> a load-failed state with the friendly error
  and a copyable detail box

The error view is a public callback-driven widget so the tests pump
each variant without a live hub. New DE+EN strings for the too-old
and load-failed states; grpcCodeOf/grpcMessageOf exposed from the
friendly-error mapper instead of duplicating the duck-typing.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-18 17:33:11 +02:00
parent b47d8c4646
commit ea2cd87b5e
8 changed files with 380 additions and 7 deletions

View file

@ -217,6 +217,16 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) {
return null;
}
/// Duck-typed `GrpcError.code` reader public so pages that
/// classify errors themselves (e.g. the runs monitor separating
/// "hub down" from "hub too old") share one accessor instead of
/// re-implementing the duck-typing.
int? grpcCodeOf(Object error) => _intField(error, 'code');
/// Duck-typed `GrpcError.message` reader companion to
/// [grpcCodeOf].
String? grpcMessageOf(Object error) => _stringField(error, 'message');
/// Try to read an `int` field by name off an arbitrary object.
/// Returns `null` when the field doesn't exist or has another
/// runtime type. Used to duck-type `GrpcError.code` without

View file

@ -1773,6 +1773,10 @@
"runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.",
"runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.",
"runsEmptyGuideButton": "Anleitung öffnen",
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
"runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.",
"runsHubTooOldButton": "Diagnose öffnen",
"runsLoadFailedTitle": "Läufe konnten nicht geladen werden",
"runsCancelButton": "Abbrechen",
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},

View file

@ -1812,6 +1812,10 @@
"runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.",
"runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.",
"runsEmptyGuideButton": "Open the guide",
"runsHubTooOldTitle": "This view needs a newer hub version",
"runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.",
"runsHubTooOldButton": "Open Doctor",
"runsLoadFailedTitle": "Runs could not be loaded",
"runsCancelButton": "Cancel",
"runsCancelSignalled": "Cancel signalled for {flow}.",
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},

View file

@ -5401,6 +5401,30 @@ abstract class AppLocalizations {
/// **'Open the guide'**
String get runsEmptyGuideButton;
/// No description provided for @runsHubTooOldTitle.
///
/// In en, this message translates to:
/// **'This view needs a newer hub version'**
String get runsHubTooOldTitle;
/// No description provided for @runsHubTooOldHint.
///
/// In en, this message translates to:
/// **'The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.'**
String get runsHubTooOldHint;
/// No description provided for @runsHubTooOldButton.
///
/// In en, this message translates to:
/// **'Open Doctor'**
String get runsHubTooOldButton;
/// No description provided for @runsLoadFailedTitle.
///
/// In en, this message translates to:
/// **'Runs could not be loaded'**
String get runsLoadFailedTitle;
/// No description provided for @runsCancelButton.
///
/// In en, this message translates to:

View file

@ -3198,6 +3198,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get runsEmptyGuideButton => 'Anleitung öffnen';
@override
String get runsHubTooOldTitle =>
'Diese Ansicht braucht eine neuere Hub-Version';
@override
String get runsHubTooOldHint =>
'Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.';
@override
String get runsHubTooOldButton => 'Diagnose öffnen';
@override
String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden';
@override
String get runsCancelButton => 'Abbrechen';

View file

@ -3195,6 +3195,19 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get runsEmptyGuideButton => 'Open the guide';
@override
String get runsHubTooOldTitle => 'This view needs a newer hub version';
@override
String get runsHubTooOldHint =>
'The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.';
@override
String get runsHubTooOldButton => 'Open Doctor';
@override
String get runsLoadFailedTitle => 'Runs could not be loaded';
@override
String get runsCancelButton => 'Cancel';

View file

@ -3,9 +3,11 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/friendly_error.dart';
import '../data/hub.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
@ -22,10 +24,138 @@ class RunsPage extends StatefulWidget {
State<RunsPage> createState() => _RunsPageState();
}
/// Why the runs monitor could not load its list. The page used to
/// fold every failure into "hub not reachable", which contradicted
/// the sidebar's green "connected" dot whenever the hub answered
/// with an RPC-level error (usertest finding: a 0.21 hub without
/// the ListInvocations RPC). Top-level so the unit test drives the
/// classification directly.
enum RunsLoadIssue {
/// The hub itself cannot be reached (socket-level failure or
/// gRPC UNAVAILABLE / DEADLINE_EXCEEDED).
unreachable,
/// The hub answered, but its version predates the
/// ListInvocations RPC (gRPC UNIMPLEMENTED) connected, just
/// too old for this view.
unsupported,
/// The hub answered and refused because detached invocations
/// are switched off (gRPC FAILED_PRECONDITION from the detached
/// gate) show the regular feature-off empty state, not a
/// connection error.
featureDisabled,
/// Anything else show the friendly error with copyable detail.
other,
}
/// Classify a [HubService.listDetachedRuns] failure into the view
/// state the page should render.
RunsLoadIssue classifyRunsLoadError(Object error) {
switch (grpcCodeOf(error)) {
case 12: // UNIMPLEMENTED hub predates the RPC
return RunsLoadIssue.unsupported;
case 9: // FAILED_PRECONDITION the hub's detached gate
final msg = (grpcMessageOf(error) ?? '').toLowerCase();
return msg.contains('detached')
? RunsLoadIssue.featureDisabled
: RunsLoadIssue.other;
case 4: // DEADLINE_EXCEEDED
case 14: // UNAVAILABLE
return RunsLoadIssue.unreachable;
}
// Non-gRPC failures: only clear socket-level shapes count as
// "unreachable"; everything else keeps its real story.
final s = error.toString().toLowerCase();
if (s.contains('socketexception') ||
s.contains('connection refused') ||
s.contains('connection terminated') ||
s.contains('failed to connect')) {
return RunsLoadIssue.unreachable;
}
return RunsLoadIssue.other;
}
/// The error rendition of the runs monitor public + callback-driven
/// so the widget test can pump each variant without a live hub.
class RunsLoadErrorView extends StatelessWidget {
final Object error;
final RunsLoadIssue issue;
/// Opens the runs guide (the feature-off empty state's CTA).
final VoidCallback onOpenGuide;
/// Opens the doctor page (the "hub too old" state's CTA, where
/// the update banner lives). Null hides the button.
final VoidCallback? onOpenDoctor;
const RunsLoadErrorView({
super.key,
required this.error,
required this.issue,
required this.onOpenGuide,
this.onOpenDoctor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
switch (issue) {
case RunsLoadIssue.featureDisabled:
// Same story as the regular feature-off empty state: the
// hub is fine, the operator just has not enabled the
// feature never claim "not reachable" here.
return ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyTitle,
hint: l.runsEmptyHint,
action: OutlinedButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.runsEmptyGuideButton),
onPressed: onOpenGuide,
),
);
case RunsLoadIssue.unsupported:
return ChainEmptyState(
icon: Icons.system_update_alt_outlined,
title: l.runsHubTooOldTitle,
hint: l.runsHubTooOldHint,
action: onOpenDoctor == null
? null
: OutlinedButton.icon(
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: Text(l.runsHubTooOldButton),
onPressed: onOpenDoctor,
),
);
case RunsLoadIssue.unreachable:
return ChainEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
);
case RunsLoadIssue.other:
return ChainEmptyState(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
title: l.runsLoadFailedTitle,
action: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ChainErrorBox(error: error, isError: true),
),
);
}
}
}
class _RunsPageState extends State<RunsPage> {
List<DetachedRun> _runs = const [];
bool _detachedEnabled = false;
String? _error;
Object? _error;
RunsLoadIssue _issue = RunsLoadIssue.other;
bool _loaded = false;
Timer? _poll;
final Set<String> _cancelling = <String>{};
@ -63,7 +193,8 @@ class _RunsPageState extends State<RunsPage> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_error = e;
_issue = classifyRunsLoadError(e);
_loaded = true;
});
}
@ -118,11 +249,12 @@ class _RunsPageState extends State<RunsPage> {
body: !_loaded
? const Center(child: CircularProgressIndicator())
: _error != null && _runs.isEmpty
? ChainEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
? RunsLoadErrorView(
error: _error!,
issue: _issue,
onOpenGuide: () => showFaiDoc(context, 'runs'),
onOpenDoctor: () =>
StudioShellState.of(context)?.navigateTo('doctor'),
)
: _runs.isEmpty
? ChainEmptyState(