feat(studio): friendlyError catches hub-specific patterns
Some checks failed
Security / Security check (push) Failing after 1s

Generic gRPC-code mapping was right but not specific enough.
A flow failing with an approval-timeout used to land on
'Deadline exceeded — try again later'; now it reads 'Freigabe-
Timeout abgelaufen — entweder timeout_seconds erhöhen oder
den Reviewer informieren.'

New pattern matchers in _matchHubPattern, runs before the
gRPC-code switch. Six FlowExecutionError shapes covered:

  - approval rejected ("rejected by")
  - approval timeout
  - output too large ("exceeding the X MB cap")
  - host service not declared
  - missing value reference
  - MCP endpoint unreachable
  - capability not installed (NotFound fallback)

Every match comes with a localised hint pointing at the
concrete fix path (audit log / timeout config / Integrations
panel / Text-tab Fix button).

Five new tests pin the matchers — would catch a silent
regression when the hub renames a variant Display string.
All 11 friendly_error tests + 19 Studio tests green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-09 02:01:36 +02:00
parent 28fafce7dc
commit 971196518c
8 changed files with 370 additions and 4 deletions

View file

@ -39,6 +39,12 @@ FriendlyError friendlyError(Object error, AppLocalizations l) {
// duck-type on those instead of an `is` check. // duck-type on those instead of an `is` check.
final code = _intField(error, 'code'); final code = _intField(error, 'code');
final detail = _stringField(error, 'message') ?? ''; 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) { if (code != null) {
switch (code) { switch (code) {
case 3: // INVALID_ARGUMENT 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. /// Try to read an `int` field by name off an arbitrary object.
/// Returns `null` when the field doesn't exist or has another /// Returns `null` when the field doesn't exist or has another
/// runtime type. Used to duck-type `GrpcError.code` without /// runtime type. Used to duck-type `GrpcError.code` without

View file

@ -30,6 +30,20 @@
"welcomeDocApprovalsTitle": "Freigaben", "welcomeDocApprovalsTitle": "Freigaben",
"welcomeDocApprovalsBlurb": "Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.", "welcomeDocApprovalsBlurb": "Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.",
"helpTooltip": "Hilfe", "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", "welcomeDocClose": "Schließen",
"welcomeDocFailedToLoad": "Doku konnte nicht geladen werden: {error}", "welcomeDocFailedToLoad": "Doku konnte nicht geladen werden: {error}",
"@welcomeDocFailedToLoad": { "@welcomeDocFailedToLoad": {

View file

@ -33,6 +33,20 @@
"welcomeDocApprovalsTitle": "Approvals", "welcomeDocApprovalsTitle": "Approvals",
"welcomeDocApprovalsBlurb": "Human-in-the-loop checkpoints — when to use them, how the audit log records them.", "welcomeDocApprovalsBlurb": "Human-in-the-loop checkpoints — when to use them, how the audit log records them.",
"helpTooltip": "Help", "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", "welcomeDocClose": "Close",
"welcomeDocFailedToLoad": "Could not load documentation: {error}", "welcomeDocFailedToLoad": "Could not load documentation: {error}",
"@welcomeDocFailedToLoad": { "@welcomeDocFailedToLoad": {

View file

@ -278,6 +278,90 @@ abstract class AppLocalizations {
/// **'Help'** /// **'Help'**
String get helpTooltip; 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. /// No description provided for @welcomeDocClose.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -111,6 +111,57 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get helpTooltip => 'Hilfe'; 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 @override
String get welcomeDocClose => 'Schließen'; String get welcomeDocClose => 'Schließen';

View file

@ -111,6 +111,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get helpTooltip => 'Help'; 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 @override
String get welcomeDocClose => 'Close'; String get welcomeDocClose => 'Close';

View file

@ -126,7 +126,7 @@ packages:
path: "../fai_studio_flow_editor" path: "../fai_studio_flow_editor"
relative: true relative: true
source: path source: path
version: "0.20.0" version: "0.20.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -74,11 +74,82 @@ void main() {
final r = final r =
friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe); friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe);
expect(r.headline, lDe.errUnavailable); 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')); final lEn = await _loadL10n(const Locale('en'));
expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable))); 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);
});
} }