diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index 6e7a030..5cb17ad 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -39,6 +39,12 @@ FriendlyError friendlyError(Object error, AppLocalizations l) { // 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 @@ -107,6 +113,82 @@ FriendlyError friendlyError(Object error, AppLocalizations l) { ); } +/// 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 diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4b8d4a2..5708e53 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -30,6 +30,20 @@ "welcomeDocApprovalsTitle": "Freigaben", "welcomeDocApprovalsBlurb": "Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.", "helpTooltip": "Hilfe", + "errApprovalRejected": "Freigabe vom Reviewer abgelehnt.", + "errApprovalRejectedHint": "Der Reviewer hat den Step abgelehnt. Begründung steht im Audit-Log.", + "errApprovalTimedOut": "Freigabe-Timeout abgelaufen.", + "errApprovalTimedOutHint": "Es wurde nicht rechtzeitig entschieden. Entweder `timeout_seconds` am Freigabe-Step erhöhen oder den Reviewer informieren.", + "errOutputTooLarge": "Step-Output hat die Größenbegrenzung überschritten.", + "errOutputTooLargeHint": "Entweder Output kürzen (Text truncaten, Bytes komprimieren) oder `max_output_size_mb` in der Operator-Config erhöhen.", + "errServiceUnavailableForStep": "Host-Service nicht konfiguriert.", + "errServiceUnavailableForStepHint": "Das Modul braucht einen Service (z.B. ollama, playwright), den der Operator nicht deklariert hat. Trage ihn unter `services:` in ~/.fai/config.yaml ein.", + "errMissingValue": "Pflichtwert fehlt.", + "errMissingValueHint": "Der Step erwartet eine Eingabe, die nicht verdrahtet ist — entweder zur Laufzeit liefern oder den vorgelagerten `$ref` korrigieren.", + "errMcpUnreachable": "MCP-Endpunkt nicht erreichbar.", + "errMcpUnreachableHint": "Der MCP-Server hat nicht geantwortet. Endpunkt in Einstellungen → Integrationen prüfen und sicherstellen, dass er läuft.", + "errCapabilityNotInstalled": "Benötigte Capability nicht installiert.", + "errCapabilityNotInstalledHint": "Im Text-Tab des Flows klickst du auf den Fix-Button — der installiert die Capability, sofern sie im Store ist.", "welcomeDocClose": "Schließen", "welcomeDocFailedToLoad": "Doku konnte nicht geladen werden: {error}", "@welcomeDocFailedToLoad": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 92f47fb..2e8c83e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -33,6 +33,20 @@ "welcomeDocApprovalsTitle": "Approvals", "welcomeDocApprovalsBlurb": "Human-in-the-loop checkpoints — when to use them, how the audit log records them.", "helpTooltip": "Help", + "errApprovalRejected": "Approval rejected by reviewer.", + "errApprovalRejectedHint": "The reviewer marked this step as rejected. Check the audit log for the reviewer's reason.", + "errApprovalTimedOut": "Approval timed out.", + "errApprovalTimedOutHint": "No reviewer decided within the configured time. Raise `timeout_seconds` on the approval step or alert the reviewer.", + "errOutputTooLarge": "Step output exceeded the size limit.", + "errOutputTooLargeHint": "Either trim the output (truncate text, downsample bytes) or raise `max_output_size_mb` in the operator config.", + "errServiceUnavailableForStep": "Host service not configured.", + "errServiceUnavailableForStepHint": "The module requires a service (e.g. ollama, playwright) the operator hasn't declared. Add it under `services:` in ~/.fai/config.yaml.", + "errMissingValue": "Required value is missing.", + "errMissingValueHint": "The step expected an input that wasn't wired up — either supply it at run time or fix the upstream `$ref`.", + "errMcpUnreachable": "MCP endpoint unreachable.", + "errMcpUnreachableHint": "The MCP server didn't respond. Check the endpoint in Settings → Integrations and confirm it's running.", + "errCapabilityNotInstalled": "Required capability is not installed.", + "errCapabilityNotInstalledHint": "Open the flow's Text tab — the analyzer's Fix button installs the capability if it's in the store.", "welcomeDocClose": "Close", "welcomeDocFailedToLoad": "Could not load documentation: {error}", "@welcomeDocFailedToLoad": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6891686..bd6684b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -278,6 +278,90 @@ abstract class AppLocalizations { /// **'Help'** String get helpTooltip; + /// No description provided for @errApprovalRejected. + /// + /// In en, this message translates to: + /// **'Approval rejected by reviewer.'** + String get errApprovalRejected; + + /// No description provided for @errApprovalRejectedHint. + /// + /// In en, this message translates to: + /// **'The reviewer marked this step as rejected. Check the audit log for the reviewer\'s reason.'** + String get errApprovalRejectedHint; + + /// No description provided for @errApprovalTimedOut. + /// + /// In en, this message translates to: + /// **'Approval timed out.'** + String get errApprovalTimedOut; + + /// No description provided for @errApprovalTimedOutHint. + /// + /// In en, this message translates to: + /// **'No reviewer decided within the configured time. Raise `timeout_seconds` on the approval step or alert the reviewer.'** + String get errApprovalTimedOutHint; + + /// No description provided for @errOutputTooLarge. + /// + /// In en, this message translates to: + /// **'Step output exceeded the size limit.'** + String get errOutputTooLarge; + + /// No description provided for @errOutputTooLargeHint. + /// + /// In en, this message translates to: + /// **'Either trim the output (truncate text, downsample bytes) or raise `max_output_size_mb` in the operator config.'** + String get errOutputTooLargeHint; + + /// No description provided for @errServiceUnavailableForStep. + /// + /// In en, this message translates to: + /// **'Host service not configured.'** + String get errServiceUnavailableForStep; + + /// No description provided for @errServiceUnavailableForStepHint. + /// + /// In en, this message translates to: + /// **'The module requires a service (e.g. ollama, playwright) the operator hasn\'t declared. Add it under `services:` in ~/.fai/config.yaml.'** + String get errServiceUnavailableForStepHint; + + /// No description provided for @errMissingValue. + /// + /// In en, this message translates to: + /// **'Required value is missing.'** + String get errMissingValue; + + /// No description provided for @errMissingValueHint. + /// + /// In en, this message translates to: + /// **'The step expected an input that wasn\'t wired up — either supply it at run time or fix the upstream `\$ref`.'** + String get errMissingValueHint; + + /// No description provided for @errMcpUnreachable. + /// + /// In en, this message translates to: + /// **'MCP endpoint unreachable.'** + String get errMcpUnreachable; + + /// No description provided for @errMcpUnreachableHint. + /// + /// In en, this message translates to: + /// **'The MCP server didn\'t respond. Check the endpoint in Settings → Integrations and confirm it\'s running.'** + String get errMcpUnreachableHint; + + /// No description provided for @errCapabilityNotInstalled. + /// + /// In en, this message translates to: + /// **'Required capability is not installed.'** + String get errCapabilityNotInstalled; + + /// No description provided for @errCapabilityNotInstalledHint. + /// + /// In en, this message translates to: + /// **'Open the flow\'s Text tab — the analyzer\'s Fix button installs the capability if it\'s in the store.'** + String get errCapabilityNotInstalledHint; + /// No description provided for @welcomeDocClose. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ba94f3c..6d55a0c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -111,6 +111,57 @@ class AppLocalizationsDe extends AppLocalizations { @override String get helpTooltip => 'Hilfe'; + @override + String get errApprovalRejected => 'Freigabe vom Reviewer abgelehnt.'; + + @override + String get errApprovalRejectedHint => + 'Der Reviewer hat den Step abgelehnt. Begründung steht im Audit-Log.'; + + @override + String get errApprovalTimedOut => 'Freigabe-Timeout abgelaufen.'; + + @override + String get errApprovalTimedOutHint => + 'Es wurde nicht rechtzeitig entschieden. Entweder `timeout_seconds` am Freigabe-Step erhöhen oder den Reviewer informieren.'; + + @override + String get errOutputTooLarge => + 'Step-Output hat die Größenbegrenzung überschritten.'; + + @override + String get errOutputTooLargeHint => + 'Entweder Output kürzen (Text truncaten, Bytes komprimieren) oder `max_output_size_mb` in der Operator-Config erhöhen.'; + + @override + String get errServiceUnavailableForStep => 'Host-Service nicht konfiguriert.'; + + @override + String get errServiceUnavailableForStepHint => + 'Das Modul braucht einen Service (z.B. ollama, playwright), den der Operator nicht deklariert hat. Trage ihn unter `services:` in ~/.fai/config.yaml ein.'; + + @override + String get errMissingValue => 'Pflichtwert fehlt.'; + + @override + String get errMissingValueHint => + 'Der Step erwartet eine Eingabe, die nicht verdrahtet ist — entweder zur Laufzeit liefern oder den vorgelagerten `\$ref` korrigieren.'; + + @override + String get errMcpUnreachable => 'MCP-Endpunkt nicht erreichbar.'; + + @override + String get errMcpUnreachableHint => + 'Der MCP-Server hat nicht geantwortet. Endpunkt in Einstellungen → Integrationen prüfen und sicherstellen, dass er läuft.'; + + @override + String get errCapabilityNotInstalled => + 'Benötigte Capability nicht installiert.'; + + @override + String get errCapabilityNotInstalledHint => + 'Im Text-Tab des Flows klickst du auf den Fix-Button — der installiert die Capability, sofern sie im Store ist.'; + @override String get welcomeDocClose => 'Schließen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6d8d401..9613671 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -111,6 +111,56 @@ class AppLocalizationsEn extends AppLocalizations { @override String get helpTooltip => 'Help'; + @override + String get errApprovalRejected => 'Approval rejected by reviewer.'; + + @override + String get errApprovalRejectedHint => + 'The reviewer marked this step as rejected. Check the audit log for the reviewer\'s reason.'; + + @override + String get errApprovalTimedOut => 'Approval timed out.'; + + @override + String get errApprovalTimedOutHint => + 'No reviewer decided within the configured time. Raise `timeout_seconds` on the approval step or alert the reviewer.'; + + @override + String get errOutputTooLarge => 'Step output exceeded the size limit.'; + + @override + String get errOutputTooLargeHint => + 'Either trim the output (truncate text, downsample bytes) or raise `max_output_size_mb` in the operator config.'; + + @override + String get errServiceUnavailableForStep => 'Host service not configured.'; + + @override + String get errServiceUnavailableForStepHint => + 'The module requires a service (e.g. ollama, playwright) the operator hasn\'t declared. Add it under `services:` in ~/.fai/config.yaml.'; + + @override + String get errMissingValue => 'Required value is missing.'; + + @override + String get errMissingValueHint => + 'The step expected an input that wasn\'t wired up — either supply it at run time or fix the upstream `\$ref`.'; + + @override + String get errMcpUnreachable => 'MCP endpoint unreachable.'; + + @override + String get errMcpUnreachableHint => + 'The MCP server didn\'t respond. Check the endpoint in Settings → Integrations and confirm it\'s running.'; + + @override + String get errCapabilityNotInstalled => + 'Required capability is not installed.'; + + @override + String get errCapabilityNotInstalledHint => + 'Open the flow\'s Text tab — the analyzer\'s Fix button installs the capability if it\'s in the store.'; + @override String get welcomeDocClose => 'Close'; diff --git a/pubspec.lock b/pubspec.lock index 029963f..913973f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -126,7 +126,7 @@ packages: path: "../fai_studio_flow_editor" relative: true source: path - version: "0.20.0" + version: "0.20.1" fake_async: dependency: transitive description: diff --git a/test/friendly_error_test.dart b/test/friendly_error_test.dart index 18364ad..e8ef45f 100644 --- a/test/friendly_error_test.dart +++ b/test/friendly_error_test.dart @@ -74,11 +74,82 @@ void main() { final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe); expect(r.headline, lDe.errUnavailable); - // Sanity: the DE headline really is different from the EN - // one — guards against the test silently passing if l10n - // regen returned EN for both. final lEn = await _loadL10n(const Locale('en')); expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable))); }); + + // --- Hub-pattern-specific detection --- + // The mapper inspects the message DETAIL for hub patterns and + // returns a more specific FriendlyError than the gRPC code's + // default. These guards lock the matchers — a regression here + // would silently fall back to the generic gRPC-code copy. + + test("approval rejection maps to specific headline", () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError( + 7, + 'PERMISSION_DENIED', + "step 'ap1' rejected by alice: needs more detail", + ), + l, + ); + expect(r.headline, l.errApprovalRejected); + expect(r.hint, l.errApprovalRejectedHint); + }); + + test('approval timeout maps to specific headline', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError( + 4, + 'DEADLINE_EXCEEDED', + "step 'ap1' timed out waiting for approval after 600s", + ), + l, + ); + expect(r.headline, l.errApprovalTimedOut); + }); + + test('output-too-large maps to specific headline', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError( + 8, + 'RESOURCE_EXHAUSTED', + "step 'extract' produced 52428800 bytes of output, " + "exceeding the 10 MB cap", + ), + l, + ); + expect(r.headline, l.errOutputTooLarge); + }); + + test('host-service-not-declared maps to specific headline', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError( + 14, + 'UNAVAILABLE', + "step 'summarise' requires service 'ollama' which is not declared " + "in operator config", + ), + l, + ); + expect(r.headline, l.errServiceUnavailableForStep); + }); + + test('missing value maps to specific headline', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError( + 3, + 'INVALID_ARGUMENT', + r"step 'classify' references missing value '$inputs.text'", + ), + l, + ); + expect(r.headline, l.errMissingValue); + }); }