Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions .agent-artifacts/ui-evidence/sync-attention/evidence.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"version": 1,
"images": [
{
"file": "001-mobile-ios-device-catching-up-pending.png",
"platform": "mobile-ios",
"description": "Synthetic Flutter widget harness, real DeviceSettings/OnTheWayPage with injected fakes, 390x844 iOS viewport: Device Settings with 8h 14m pending: the third stat tile reads Catching up / 8h 14m / on its way, and opens On the Way. Right now no longer has a catching-up block. The blank band above the tiles is the device image, which the test engine does not paint.",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "002-mobile-ios-device-nothing-pending.png",
"platform": "mobile-ios",
"description": "Synthetic Flutter widget harness, real DeviceSettings/OnTheWayPage with injected fakes, 390x844 iOS viewport: Device Settings with nothing pending: the third tile is On Device again.",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "003-mobile-ios-on-the-way-mixed-days.png",
"platform": "mobile-ios",
"description": "Synthetic Flutter widget harness, real DeviceSettings/OnTheWayPage with injected fakes, 390x844 iOS viewport: On the Way, option B, light: headline, thin progress bar, one line; one card with a row per day (Uploading / Transcribing / Queued / Next); footnote and Details for support. Fixture also holds a too-old recording and two refusals, which are not rows.",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "004-mobile-ios-on-the-way-support-details.png",
"platform": "mobile-ios",
"description": "Synthetic Flutter widget harness, real DeviceSettings/OnTheWayPage with injected fakes, 390x844 iOS viewport: Details for support: every recording with its stage, and refused/failed ones with their reason tokens; no Try Again or Delete.",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "005-mobile-ios-on-the-way-dark.png",
"platform": "mobile-ios",
"description": "Synthetic Flutter widget harness, real DeviceSettings/OnTheWayPage with injected fakes, 390x844 iOS viewport: On the Way in dark mode.",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "006-mobile-ios-on-the-way-largest-text.png",
"platform": "mobile-ios",
"description": "Synthetic Flutter widget harness, real DeviceSettings/OnTheWayPage with injected fakes, 390x844 iOS viewport: On the Way at iOS's largest accessibility text (3.1x): rows stack the status under the totals; nothing clips.",
"captured_by": "agent",
"source": "visual-audit"
}
]
}
10 changes: 10 additions & 0 deletions .github/failure-classes/FC-sync-refusal-spends-user-attention.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schema_version": 1,
"id": "FC-sync-refusal-spends-user-attention",
"violated_contract": "A deterministic sync upload refusal without a user repair must preserve audio and produce bounded operator feedback without repeated user Retry prompts or an unbounded pending-work time span.",
"canonical_prevention": "Persist closed refusal reasons and a daily recovery clock separately from transient retry budgets. Keep refused audio outside user attention, expose an aggregate, accept no-audio clips as no-ops, and derive pending spans from current work with authoritative job reconciliation.",
"canonical_prevention_artifact": ["app/test/unit/sync_attention_policy_test.dart", "backend/tests/unit/test_sync_upload_attention.py"],
"evidence_prs": [],
"scope_hints": ["app/lib/services/wals/", "backend/utils/sync/empty_upload.py"],
"status": "open"
}
3 changes: 3 additions & 0 deletions app/changelog/unreleased/20261028-sync-attention.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Recordings refused by the server now recover automatically without repeated Try Again prompts. Device settings shows how much is catching up in its stat tiles, and On the Way lists it by day, with a plain detail list for support"
}
23 changes: 11 additions & 12 deletions app/integration_test/visual_audit/scenarios/offline_processing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,11 @@ final offlineProcessingScenarios = <AuditScenario>[
_wal(_at(0, 15, 0), 70, WalStatus.miss, storage: WalStorage.sdcard),
_wal(_at(0, 11, 0), 110, WalStatus.miss, storage: WalStorage.sdcard),
]));
expect(find.text('Catching up'), findsOneWidget);
expect(find.byKey(const Key('device_catching_up_status')), findsOneWidget);
final tile = find.byKey(const Key('device_stat_catching_up'));
expect(tile, findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsNothing);
expect(find.byKey(const Key('device_catching_up_span')), findsOneWidget);
await a.shot('Large backlog lives on Device with its status and audio span');
expect(find.descendant(of: tile, matching: find.text('3h')), findsOneWidget);
await a.shot('Large backlog is the Catching up tile: a duration, no stage or span');
},
),
for (final multiple in [false, true])
Expand All @@ -312,14 +312,13 @@ final offlineProcessingScenarios = <AuditScenario>[
startedAt: _at(0, 11, 0),
durationSeconds: 720),
]);
expect(find.text('Catching up'), findsOneWidget);
expect(find.text('Processing'), findsOneWidget);
expect(find.byKey(const Key('device_catching_up_on_the_way')), findsOneWidget);
await a.shot('Server work stays visible on Device until it finishes');
await a.tester.tap(find.byKey(const Key('device_catching_up_on_the_way')));
expect(find.descendant(of: find.byKey(const Key('device_stat_catching_up')), matching: find.text('12m')),
findsOneWidget);
await a.shot('Server work stays on the Catching up tile until it finishes');
await a.tester.tap(find.byKey(const Key('device_stat_catching_up')));
await a.settle();
expect(find.byType(OnTheWayPage), findsOneWidget);
await a.shot('The card opens On the Way', step: 'on-the-way');
await a.shot('The tile opens On the Way', step: 'on-the-way');
},
),
AuditScenario(
Expand All @@ -346,8 +345,8 @@ final offlineProcessingScenarios = <AuditScenario>[
providers: [ChangeNotifierProvider<SyncProvider>.value(value: sync)],
);
expect(find.text('On the Way'), findsOneWidget);
expect(find.text('Sync details'), findsOneWidget);
await a.shot('Open On the Way from the device catch-up card');
expect(find.text('Details for support'), findsOneWidget);
await a.shot('Open On the Way from the device Catching up tile');
},
),
AuditScenario(
Expand Down
69 changes: 12 additions & 57 deletions app/lib/backend/http/api/conversations.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'sync_upload_refusal.dart';

Check warning on line 1 in app/lib/backend/http/api/conversations.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/backend/http/api/conversations.dart is 1437 lines; consider splitting files over 800 lines.
import 'dart:convert';
import 'dart:io';
import 'dart:math';
Expand Down Expand Up @@ -788,14 +789,15 @@
class SyncUploadHttpException implements Exception {
final int statusCode;
final String message;
final String? reasonCode;

/// The server's `Retry-After` (delta-seconds) on a 5xx, when provided. The
/// sync dispatch and ledger-fence refusals carry one so the client pauses
/// instead of failing every remaining batch on the same gate; null when the
/// server gave no pacing guidance (e.g. platform-generated 503s).
final int? retryAfterSeconds;

const SyncUploadHttpException(this.statusCode, this.message, {this.retryAfterSeconds});
const SyncUploadHttpException(this.statusCode, this.message, {this.retryAfterSeconds, this.reasonCode});

@override
String toString() => 'SyncUploadHttpException(status=$statusCode): $message';
Expand Down Expand Up @@ -879,57 +881,6 @@
}
}

/// Bounded `code` the sync upload route puts on a 400 refusal, or null when
/// the body carries none. Never parses human-readable text into behavior.
@visibleForTesting
String? syncUploadErrorCode(http.Response response) {
try {
// omi-perf-allow: history-json-on-ui -- a small error body, never a conversation list
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final code = decoded['code'];
if (code is String && code.trim().isNotEmpty) return code.trim();
}
} catch (_) {}
return null;
}

/// The server's own one-line reason from a 400 body, for logs and terminal
/// rows: kept verbatim but bounded, so display surfaces never echo an
/// unbounded error string.
@visibleForTesting
String? syncUploadErrorReason(http.Response response) {
try {
// omi-perf-allow: history-json-on-ui -- a small error body, never a conversation list
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
for (final key in ['detail', 'message', 'error']) {
final value = decoded[key];
if (value is String && value.trim().isNotEmpty) {
final reason = value.trim();
return reason.length > 200 ? reason.substring(0, 200) : reason;
}
}
}
} catch (_) {}
return null;
}

/// The exact detail the deployed upload route puts on a 400 whose filename
/// timestamp it refuses (`utils/sync/pipeline.py`, before the bounded `code`
/// field existed). Matched verbatim — never a substring of other refusals.
const String _kInvalidCaptureTimeDetail = 'Invalid sync file format: invalid timestamp';

/// Whether a sync-upload 400 refuses the recording's claimed capture time.
/// The server names this refusal two ways: the bounded `code`
/// `invalid_capture_time` (server PR #23746) or, on the currently deployed
/// route, the exact detail line above with no code at all.
@visibleForTesting
bool isInvalidCaptureTimeResponse(http.Response response) {
if (syncUploadErrorCode(response) == 'invalid_capture_time') return true;
return syncUploadErrorReason(response) == _kInvalidCaptureTimeDetail;
}

/// Thrown when the server refuses an upload because its filename carries a
/// capture time it will not accept (HTTP 400 `invalid_capture_time`): before
/// 2024-01-01 or in the future. The audio is fine — only its claimed time is
Expand Down Expand Up @@ -1108,20 +1059,24 @@
return UploadFilesResult.queued(start.jobId, pollAfterMs: start.pollAfterMs);
}
if (response.statusCode == 400) {
if (isInvalidCaptureTimeResponse(response)) {
throw SyncInvalidCaptureTimeException(serverReason: syncUploadErrorReason(response));
final refusal = switch (decodeSyncUploadRefusal(response)) {
ApiSuccess(:final data) => data,
ApiFailure() => const SyncUploadRefusalBody(),
};
if (refusal.invalidCaptureTime) {
throw const SyncInvalidCaptureTimeException(serverReason: 'invalid_capture_time');
}
// Other 400s stay terminal, but the server's own reason travels with the
// refusal instead of being dropped at the HTTP boundary.
final reason = syncUploadErrorReason(response);
throw SyncUploadHttpException(
response.statusCode,
reason ?? 'Audio file could not be processed by server',
'Audio file could not be processed by server',
reasonCode: refusal.reasonCode,
);
} else if (response.statusCode == 401 || response.statusCode == 403) {
throw SyncUploadHttpException(response.statusCode, 'Upload authentication failed');
} else if (response.statusCode == 413) {
throw SyncUploadHttpException(response.statusCode, 'Audio file is too large to upload');
throw SyncUploadHttpException(response.statusCode, 'Audio file is too large to upload', reasonCode: 'too_large');
} else if (isSyncContentInProgressResponse(response)) {
final existingJob = syncContentInProgressResult(response);
if (existingJob != null) return existingJob;
Expand Down
68 changes: 68 additions & 0 deletions app/lib/backend/http/api/sync_upload_refusal.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:omi/backend/http/api_result.dart';

/// Optional refusal metadata, separate from the authoritative HTTP failure.
class SyncUploadRefusalBody {
const SyncUploadRefusalBody({this.code, this.detail});
final String? code;
final String? detail;

bool get invalidCaptureTime =>
code == 'invalid_capture_time' || detail == 'Invalid sync file format: invalid timestamp';

/// Only closed tokens can reach the WAL ledger or analytics.
String get reasonCode {
const tokens = {
'invalid_capture_time',
'invalid_file',
'malformed_multipart',
'too_many_parts',
'too_large',
'empty_clip',
'server_refused'
};
if (tokens.contains(code)) return code!;
if (invalidCaptureTime) return 'invalid_capture_time';
if (detail == 'Uploaded file is missing a filename' || detail == 'Invalid sync file format') return 'invalid_file';
if (detail == 'Missing boundary in multipart.' ||
detail == 'Malformed multipart body' ||
detail == 'The Content-Disposition header field "name" must be provided.') {
return 'malformed_multipart';
}
if (detail?.startsWith('Part exceeded maximum size of ') ?? false) return 'too_large';
if ((detail?.startsWith('Too many files. Maximum number of files is ') ?? false) ||
(detail?.startsWith('Too many fields. Maximum number of fields is ') ?? false)) {
return 'too_many_parts';
}
return 'server_refused';
}
}

String? _optionalText(Object? value) {
final text = value is String ? value.trim() : null;
return text == null || text.isEmpty ? null : text.substring(0, text.length > 200 ? 200 : text.length);
}

/// Decode once. A malformed body is an explicit decode failure; the upload
/// caller still retains the HTTP refusal and its audio, with an unknown token.
ApiResult<SyncUploadRefusalBody> decodeSyncUploadRefusal(http.Response response) {
try {
// omi-perf-allow: history-json-on-ui -- a small error body, never a conversation list
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return ApiFailure(ApiProblem(ApiProblemKind.decode, statusCode: response.statusCode));
}
final detail = decoded['detail'];
final code = decoded['code'] ?? (detail is Map ? detail['code'] : null);
String? reason;
for (final key in ['detail', 'message', 'error']) {
reason = _optionalText(decoded[key]);
if (reason != null) break;
}
return ApiSuccess(SyncUploadRefusalBody(code: _optionalText(code), detail: reason));
} on FormatException {
return ApiFailure(ApiProblem(ApiProblemKind.decode, statusCode: response.statusCode));
}
}
17 changes: 12 additions & 5 deletions app/lib/l10n/app_ar.arb
Original file line number Diff line number Diff line change
Expand Up @@ -4174,10 +4174,6 @@
"onTheWayTitle": "في الطريق",
"onTheWayAlmostReady": "على وشك الجاهزية",
"onTheWayRecordingCount": "{count, plural, =1{تسجيل واحد} other{التسجيلات: {count}}}",
"onTheWaySummary": "{totals} في الطريق",
"onTheWayUploadingCount": "قيد الرفع: {count}",
"onTheWayAlmostReadyCount": "على وشك الجاهزية: {count}",
"onTheWayNeedsAttentionCount": "{count, plural, =1{تسجيل واحد بحاجة إلى المراجعة} other{بحاجة إلى المراجعة: {count}}}",
"activityEmpty": "لا يوجد نشاط بعد",
"activityNewSemantics": "النشاط، عناصر جديدة",
"activityLoadFailed": "تعذّر تحميل كل نشاطك",
Expand Down Expand Up @@ -4452,5 +4448,16 @@
"livePreviewNoEngineBody": "تظهر كلماتك هنا بعد دقيقة أو دقيقتين من قولها.",
"livePreviewNoEngineTitle": "يسجّل بأمان",
"livePreviewOnPhoneDivider": "مباشر على هذا الهاتف",
"livePreviewTurnOnLiveText": "تشغيل النص المباشر"
"livePreviewTurnOnLiveText": "تشغيل النص المباشر",
"deviceCatchUpOnItsWay": "في الطريق",
"deviceCatchUpWhenOnline": "عند الاتصال بالإنترنت",
"onTheWayCatchingUp": "{duration} قيد الرفع",
"onTheWayUploadsByItself": "يُرفَع تلقائيًا. تظهر المحادثات فور وصول التسجيلات.",
"onTheWayUploadsWhenOnline": "يُرفَع تلقائيًا بمجرد عودة الاتصال بالإنترنت. تظهر المحادثات فور وصول التسجيلات.",
"onTheWayDayUploading": "جارٍ الرفع",
"onTheWayDayNext": "التالي",
"onTheWayDayQueued": "في قائمة الانتظار",
"onTheWayNoDate": "بدون تاريخ",
"onTheWayFootnote": "تستمر التسجيلات التي تفشل في إعادة المحاولة في الخلفية. ويُبلَّغنا تلقائيًا عن أي شيء يتعذر علينا تحويله إلى نص.",
"onTheWayDetailsForSupport": "تفاصيل لفريق الدعم"
}
17 changes: 12 additions & 5 deletions app/lib/l10n/app_be.arb
Original file line number Diff line number Diff line change
Expand Up @@ -11597,10 +11597,6 @@
"onTheWayTitle": "У дарозе",
"onTheWayAlmostReady": "Амаль гатова",
"onTheWayRecordingCount": "{count, plural, =1{1 запіс} other{Запісаў: {count}}}",
"onTheWaySummary": "{totals} у дарозе",
"onTheWayUploadingCount": "Запампоўваецца: {count}",
"onTheWayAlmostReadyCount": "Амаль гатова: {count}",
"onTheWayNeedsAttentionCount": "{count, plural, =1{1 патрабуе ўвагі} other{Патрабуюць увагі: {count}}}",
"activityEmpty": "Пакуль няма актыўнасці",
"activityNewSemantics": "Актыўнасць, новыя элементы",
"activityLoadFailed": "Не ўдалося загрузіць усю вашу актыўнасць",
Expand Down Expand Up @@ -11878,5 +11874,16 @@
"livePreviewNoEngineBody": "Вашы словы з’явяцца тут праз хвіліну-две пасля таго, як вы іх вымовіце.",
"livePreviewNoEngineTitle": "Бяспечны запіс",
"livePreviewOnPhoneDivider": "Нажыва на гэтым тэлефоне",
"livePreviewTurnOnLiveText": "Уключыць жывы тэкст"
"livePreviewTurnOnLiveText": "Уключыць жывы тэкст",
"deviceCatchUpOnItsWay": "у дарозе",
"deviceCatchUpWhenOnline": "пры злучэнні з інтэрнэтам",
"onTheWayCatchingUp": "{duration} яшчэ загружаецца",
"onTheWayUploadsByItself": "Загрузка ідзе аўтаматычна. Размовы з'яўляюцца па меры прыбыцця запісаў.",
"onTheWayUploadsWhenOnline": "Загрузка ідзе аўтаматычна, калі вы зноў будзеце онлайн. Размовы з'яўляюцца па меры прыбыцця запісаў.",
"onTheWayDayUploading": "Загружаецца",
"onTheWayDayNext": "Наступныя",
"onTheWayDayQueued": "У чарзе",
"onTheWayNoDate": "Без даты",
"onTheWayFootnote": "Запісы, якія не ўдалося загрузіць, працягнуць спробы ў фонавым рэжыме. Пра ўсё, што мы не зможам пераўтварыць у тэкст, нам паведамяць аўтаматычна.",
"onTheWayDetailsForSupport": "Падрабязнасці для падтрымкі"
}
Loading
Loading