From 5f56abcf5f8f1a1a5e32d9cbd2fc9bde39386dad Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 13:02:21 +0200 Subject: [PATCH] =?UTF-8?q?fix(studio):=20audit=20batch=20=E2=80=94=20debu?= =?UTF-8?q?g.echo=20install,=20copyable=20errors,=20fast=20Diagnose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a live-test audit: - Install via the flow-editor 'Fix' sent 'debug.echo@^0' (version constraint included) as the install source; the hub resolves by bare name so it missed ('no store entry for debug.echo@^0'). Strip the @ like the Store page does — debug.echo now installs. - Errors the operator could not copy: route the daemon-start failure (its stderr!), the flow-editor install failure and the federation issue failure through showFaiErrorSnack / a new showFaiProcessError helper, so every error is selectable, one-tap copyable and logged. - Diagnose page opened slowly because doctor() Future.wait-ed on a live manifest fetch (8s server timeout); cap that one check at 2s so the page no longer waits on the network. Signed-off-by: flemming-it --- lib/data/error_presentation.dart | 24 ++++++++++++++++ lib/data/hub.dart | 13 ++++++++- lib/main.dart | 47 +++++++++++++++++++++----------- lib/pages/federation.dart | 6 ++-- lib/pages/flows.dart | 25 ++++++++++------- 5 files changed, 85 insertions(+), 30 deletions(-) diff --git a/lib/data/error_presentation.dart b/lib/data/error_presentation.dart index 27ee75a..536fc1f 100644 --- a/lib/data/error_presentation.dart +++ b/lib/data/error_presentation.dart @@ -85,6 +85,30 @@ void showFaiErrorSnack( ); } +/// Surface a failed external-process result (daemon start, binary +/// probe, file-open) as a copyable error snack. The `ok/stdout/stderr` +/// record these actions return is not a thrown error, so call sites used +/// to drop it into a raw `SnackBar(Text(...))` — uncopyable, the exact +/// thing the operator needs to paste back. This joins stderr + stdout +/// into one selectable, one-tap-copyable, logged message. +void showFaiProcessError( + BuildContext context, + String source, + String stdout, + String stderr, { + String? title, +}) { + final detail = [stderr.trim(), stdout.trim()] + .where((s) => s.isNotEmpty) + .join('\n\n'); + showFaiErrorSnack( + context, + source, + detail.isEmpty ? 'process exited non-zero (no output)' : detail, + title: title, + ); +} + /// Show [error] as a centered modal dialog. Use this when the /// failure blocks meaningful interaction (auth missing, hub /// unreachable) — a SnackBar would be too easy to dismiss. diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 559dcaf..f4a7985 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -902,7 +902,18 @@ class HubService { .catchError((_) => []), _client.verifyEventChain().catchError((_) => VerifyEventChainResponse()), _client.listServices().catchError((_) => []), - _client.checkUpdate().catchError((_) => CheckUpdateResponse()), + // checkUpdate does a live manifest fetch (server-side 8s timeout). + // The other five are local-socket RPCs that return in ms, so cap + // this one tightly — otherwise it alone gates the whole Diagnose + // page open. A timeout reads as "release host unreachable", which + // the UI already renders as an offline banner, not an error. + _client + .checkUpdate() + .timeout( + const Duration(seconds: 2), + onTimeout: () => CheckUpdateResponse(), + ) + .catchError((_) => CheckUpdateResponse()), _client.daemonPaths().catchError((_) => DaemonPathsResponse()), ]); diff --git a/lib/main.dart b/lib/main.dart index 4e9d61a..f0e94c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'data/chain_log.dart'; +import 'data/error_presentation.dart'; import 'data/hub.dart'; import 'data/system_actions.dart'; import 'data/theme_plugin.dart'; @@ -270,14 +271,21 @@ class StudioShellState extends State { final l = AppLocalizations.of(context)!; final r = await SystemActions.chainDaemon(['start']); if (!mounted) return; - final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), - ), - ), - ); + if (r.ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.daemonStartRequested)), + ); + } else { + // Surface the daemon's stderr/stdout as a copyable error — this + // is the message the operator most often needs to paste back. + showFaiProcessError( + context, + 'daemon.start', + r.stdout, + r.stderr, + title: l.daemonStartFailed(''), + ); + } // Probe again shortly so the UI flips to "connected" without // waiting for the next 5 s tick. Future.delayed(const Duration(seconds: 1), _checkHealth); @@ -690,14 +698,21 @@ class _SidebarState extends State<_Sidebar> final l = AppLocalizations.of(context)!; final r = await SystemActions.chainDaemon(['start']); if (!context.mounted) return; - final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), - ), - ), - ); + if (r.ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.daemonStartRequested)), + ); + } else { + // Surface the daemon's stderr/stdout as a copyable error — this + // is the message the operator most often needs to paste back. + showFaiProcessError( + context, + 'daemon.start', + r.stdout, + r.stderr, + title: l.daemonStartFailed(''), + ); + } } @override diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index 3e26988..a193380 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -5,6 +5,7 @@ import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; +import '../data/error_presentation.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; @@ -48,9 +49,8 @@ class _FederationPageState extends State { _refresh(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.federationIssueFailed(e.toString()))), - ); + showFaiErrorSnack(context, 'federation.issue', e, + title: l.federationIssueFailed('')); } } diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 4120bf1..752b9ff 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -13,6 +13,7 @@ import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; import 'package:flutter/material.dart'; +import '../data/error_presentation.dart'; import '../data/flow_run_driver.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; @@ -94,7 +95,13 @@ class _FlowsPageState extends State { /// list so the editor can re-analyze without waiting for the /// parent widget rebuild. Future?> _onInstallCapability(String capability) async { - return _runInstall(source: capability); + // The analyzer hands us the full `use:` value (e.g. `debug.echo@^0`). + // The hub resolves install sources by bare capability name; the + // `@` suffix would make the store lookup miss ("no store + // entry for 'debug.echo@^0'"). Strip it — same as the Store page. + final at = capability.indexOf('@'); + final bare = at < 0 ? capability : capability.substring(0, at); + return _runInstall(source: bare); } /// `Add source for …` handler. Prompts the operator @@ -123,15 +130,13 @@ class _FlowsPageState extends State { } catch (e) { if (mounted) { final l = AppLocalizations.of(context); - final msg = l != null - ? l.installFailed(e.toString()) - : 'Install failed: $e'; - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - behavior: SnackBarBehavior.floating, - ), + // Copyable error (was a plain SnackBar) — install failures + // carry the real cause (network, signature, store lookup). + showFaiErrorSnack( + context, + 'flows.install', + e, + title: l != null ? l.installFailed('') : 'Install failed', ); } return null;