// Friendly-error mapper. Turns a thrown object (typically a // `GrpcError` from package:grpc) into a short human sentence // plus an optional recovery hint, ready to drop into the UI. // // Why this exists: Studio used to render `GrpcError.toString()` // verbatim — operators saw walls of text like // "gRPC Error (code: 13, codeName: INTERNAL, message: ..., details: [], rawResponse: null, trailers: {...})". // The friendly mapper folds every gRPC code into a one-line // explanation we can localise, plus a recovery action when one // is obvious (open settings, restart hub, retry). import '../l10n/app_localizations.dart'; /// Result of running an error through [friendlyError]. The /// `headline` is the short user-facing sentence; `detail` is /// the verbatim original error string (kept around in case the /// operator wants to copy-paste it into a bug report). `hint` /// is a one-sentence recovery suggestion or `null`. class FriendlyError { final String headline; final String detail; final String? hint; const FriendlyError({ required this.headline, required this.detail, this.hint, }); } /// Map an arbitrary thrown object to a [FriendlyError]. Always /// returns a value — never throws — so callers can drop the /// result straight into UI without try/catch ceremony. FriendlyError friendlyError(Object error, AppLocalizations l) { // We deliberately don't import package:grpc here so Studio // doesn't have to add it to its own pubspec — the dependency // lives one layer down in fai_client_sdk. `GrpcError` has a // stable `.code` (int) and `.message` (String?) shape; we // duck-type on those instead of an `is` check. final code = _intField(error, 'code'); final detail = _stringField(error, 'message') ?? ''; // Hub-specific pattern detection — runs BEFORE the gRPC-code // switch so a precise hit ("approval rejected by ...") wins // over the generic "permission denied" label even though // both correspond to gRPC PERMISSION_DENIED. final specific = _matchHubPattern(detail, l); if (specific != null) return specific; if (code != null) { switch (code) { case 3: // INVALID_ARGUMENT return FriendlyError( headline: l.errInvalidArgument, detail: detail, hint: l.errInvalidArgumentHint, ); case 5: // NOT_FOUND return FriendlyError( headline: l.errNotFound, detail: detail, hint: l.errNotFoundHint, ); case 6: // ALREADY_EXISTS return FriendlyError( headline: l.errAlreadyExists, detail: detail, hint: null, ); case 7: // PERMISSION_DENIED return FriendlyError( headline: l.errPermissionDenied, detail: detail, hint: l.errPermissionDeniedHint, ); case 9: // FAILED_PRECONDITION return FriendlyError( headline: l.errFailedPrecondition, detail: detail, hint: l.errFailedPreconditionHint, ); case 13: // INTERNAL return FriendlyError( headline: l.errInternal, detail: detail, hint: l.errInternalHint, ); case 14: // UNAVAILABLE return FriendlyError( headline: l.errUnavailable, detail: detail, hint: l.errUnavailableHint, ); case 16: // UNAUTHENTICATED return FriendlyError( headline: l.errUnauthenticated, detail: detail, hint: l.errUnauthenticatedHint, ); default: final codeName = _stringField(error, 'codeName') ?? 'gRPC $code'; return FriendlyError( headline: '$codeName: ${detail.isEmpty ? l.errGeneric : detail}', detail: detail, hint: null, ); } } // Non-gRPC error (e.g. FormatException from a bad URL). Show // its toString — but as the headline, not buried in a wall. return FriendlyError( headline: error.toString(), detail: error.toString(), hint: null, ); } /// Pattern-match the gRPC error message for hub-specific /// failure shapes. The hub's [FlowExecutionError] uses /// well-shaped Display formats (e.g. /// `step 'ap1' rejected by alice: ...`) — we read the prefix /// to return a more specific [FriendlyError] than the /// gRPC-code default. Returns null when nothing matches. FriendlyError? _matchHubPattern(String detail, AppLocalizations l) { if (detail.isEmpty) return null; // Approval rejected: "step 'X' rejected by Y: reason" if (detail.contains('rejected by')) { return FriendlyError( headline: l.errApprovalRejected, detail: detail, hint: l.errApprovalRejectedHint, ); } // Approval timeout: "step 'X' timed out waiting for approval after Ns" if (detail.contains('timed out waiting for approval')) { return FriendlyError( headline: l.errApprovalTimedOut, detail: detail, hint: l.errApprovalTimedOutHint, ); } // Output too large: "step 'X' produced N bytes of output, exceeding the M MB cap" if (detail.contains('exceeding the') && detail.contains('MB cap')) { return FriendlyError( headline: l.errOutputTooLarge, detail: detail, hint: l.errOutputTooLargeHint, ); } // Host service unavailable: "step 'X' requires service 'Y' which is not declared" if (detail.contains('requires service') && detail.contains('not declared')) { return FriendlyError( headline: l.errServiceUnavailableForStep, detail: detail, hint: l.errServiceUnavailableForStepHint, ); } // Missing value: "step 'X' references missing value 'Y'" if (detail.contains('references missing value')) { return FriendlyError( headline: l.errMissingValue, detail: detail, hint: l.errMissingValueHint, ); } // MCP server unreachable — hub surfaces these as Unavailable // with the MCP transport name in the message. if (detail.toLowerCase().contains('mcp') && (detail.contains('unreachable') || detail.contains('connection refused') || detail.contains('timed out'))) { return FriendlyError( headline: l.errMcpUnreachable, detail: detail, hint: l.errMcpUnreachableHint, ); } // Capability not in registry — hub returns NotFound with this // shape from the flow engine. if (detail.toLowerCase().contains('no capability provider') || detail.toLowerCase().contains('capability not installed')) { return FriendlyError( headline: l.errCapabilityNotInstalled, detail: detail, hint: l.errCapabilityNotInstalledHint, ); } return null; } /// 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 /// pulling package:grpc as a Studio dependency. int? _intField(Object obj, String field) { try { final dyn = obj as dynamic; // ignore: avoid_dynamic_calls final v = (() { switch (field) { case 'code': return dyn.code; } return null; })(); return v is int ? v : null; } catch (_) { return null; } } String? _stringField(Object obj, String field) { try { final dyn = obj as dynamic; // ignore: avoid_dynamic_calls final v = (() { switch (field) { case 'message': return dyn.message; case 'codeName': return dyn.codeName; } return null; })(); return v is String ? v : null; } catch (_) { return null; } }