neoagent 3.0.1-beta.8 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.css +78 -0
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +165 -42
  44. package/server/admin/index.html +80 -5
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/http/routes.js +1 -0
  48. package/server/http/static.js +4 -2
  49. package/server/public/.last_build_id +1 -1
  50. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  51. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  52. package/server/public/canvaskit/wimp.wasm +0 -0
  53. package/server/public/flutter_bootstrap.js +2 -2
  54. package/server/public/main.dart.js +91077 -87037
  55. package/server/routes/account.js +53 -0
  56. package/server/routes/admin.js +345 -64
  57. package/server/routes/agents.js +203 -21
  58. package/server/routes/billing.js +5 -0
  59. package/server/routes/browser.js +8 -1
  60. package/server/routes/recordings.js +96 -6
  61. package/server/routes/screenHistory.js +140 -2
  62. package/server/routes/timeline.js +43 -0
  63. package/server/services/account/erasure.js +263 -0
  64. package/server/services/ai/hooks.js +4 -1
  65. package/server/services/ai/loop/agent_engine_core.js +8 -1
  66. package/server/services/ai/loop/blank_recovery.js +36 -0
  67. package/server/services/ai/loop/conversation_loop.js +166 -33
  68. package/server/services/ai/messagingFallback.js +22 -0
  69. package/server/services/ai/rate_limits.js +28 -5
  70. package/server/services/ai/systemPrompt.js +6 -5
  71. package/server/services/ai/taskAnalysis.js +2 -0
  72. package/server/services/ai/toolEvidence.js +15 -0
  73. package/server/services/ai/toolResult.js +40 -0
  74. package/server/services/ai/tools.js +163 -4
  75. package/server/services/android/controller.js +6 -2
  76. package/server/services/billing/plans.js +2 -1
  77. package/server/services/browser/anti_detection.js +192 -0
  78. package/server/services/browser/controller.js +180 -54
  79. package/server/services/desktop/auth.js +3 -0
  80. package/server/services/desktop/registry.js +50 -2
  81. package/server/services/integrations/google/calendar.js +22 -14
  82. package/server/services/manager.js +12 -42
  83. package/server/services/memory/ingestion_chunking.js +268 -0
  84. package/server/services/messaging/telnyx.js +9 -8
  85. package/server/services/recordings/manager.js +60 -27
  86. package/server/services/runtime/backends/local-vm.js +40 -22
  87. package/server/services/runtime/docker-vm-manager.js +157 -266
  88. package/server/services/runtime/guest_bootstrap.js +17 -5
  89. package/server/services/runtime/guest_image.js +188 -0
  90. package/server/services/runtime/manager.js +0 -1
  91. package/server/services/runtime/validation.js +3 -8
  92. package/server/services/social_video/service.js +60 -10
  93. package/server/services/tasks/runtime.js +234 -9
  94. package/server/services/tasks/schedule_utils.js +5 -5
  95. package/server/services/tasks/task_repository.js +13 -0
  96. package/server/services/timeline/service.js +558 -0
  97. package/server/services/wearable/gateway.js +1 -1
  98. package/server/services/websocket.js +21 -3
  99. package/server/services/desktop/screenRecorder.js +0 -292
  100. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -5,9 +5,12 @@ import 'package:desktop_audio_capture/audio_capture.dart';
5
5
  import 'package:flutter/foundation.dart';
6
6
  import 'package:flutter/services.dart';
7
7
  import 'package:http/http.dart' as http;
8
+ import 'package:path_provider/path_provider.dart';
8
9
 
9
10
  import 'diagnostics_logger.dart';
10
11
  import 'recording_bridge.dart';
12
+ import 'recording_chunk_queue.dart';
13
+ import 'recording_chunk_queue_io.dart';
11
14
 
12
15
  RecordingBridge createPlatformRecordingBridge() {
13
16
  if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
@@ -349,15 +352,16 @@ class DesktopRecordingBridge extends RecordingBridge {
349
352
  'microphone': 0,
350
353
  'system': 0,
351
354
  };
352
- final Map<String, Future<void>> _uploadQueueBySource = <String, Future<void>>{
353
- 'microphone': Future<void>.value(),
354
- 'system': Future<void>.value(),
355
- };
356
355
  final Map<String, int> _bytesPerSecondBySource = <String, int>{
357
356
  'microphone': _sampleRate * _channels * _bytesPerSample,
358
357
  'system': _sampleRate * _channels * _bytesPerSample,
359
358
  };
360
359
 
360
+ // Chunks are persisted to disk before upload and retried until confirmed, so a
361
+ // transient network failure during a meeting never loses audio.
362
+ DiskChunkUploadQueue? _uploadQueue;
363
+ String? _systemPermissionErrorMessage;
364
+
361
365
  StreamSubscription<Uint8List>? _micAudioSub;
362
366
  StreamSubscription<Uint8List>? _systemAudioSub;
363
367
  StreamSubscription<MicAudioStatus>? _micStatusSub;
@@ -411,6 +415,7 @@ class DesktopRecordingBridge extends RecordingBridge {
411
415
 
412
416
  try {
413
417
  await _ensureDesktopPermissions();
418
+ await _initUploadQueue(sessionId);
414
419
  await _startStreams();
415
420
  _status = _status.copyWith(
416
421
  active: true,
@@ -443,7 +448,7 @@ class DesktopRecordingBridge extends RecordingBridge {
443
448
  return;
444
449
  }
445
450
  await _stopStreams(flushPending: true);
446
- await Future.wait(_uploadQueueBySource.values);
451
+ await _uploadQueue?.flush();
447
452
  _status = _status.copyWith(
448
453
  paused: true,
449
454
  activeSources: const <String>[],
@@ -528,7 +533,7 @@ class DesktopRecordingBridge extends RecordingBridge {
528
533
  }
529
534
  final sessionId = _sessionId;
530
535
  await _stopStreams(flushPending: true);
531
- await Future.wait(_uploadQueueBySource.values);
536
+ await _uploadQueue?.flush();
532
537
  _status = _status.copyWith(
533
538
  active: false,
534
539
  paused: false,
@@ -614,9 +619,22 @@ class DesktopRecordingBridge extends RecordingBridge {
614
619
  await _systemCapture.stopCapture();
615
620
  } catch (_) {}
616
621
 
622
+ await _uploadQueue?.dispose();
617
623
  _httpClient.close();
618
624
  }
619
625
 
626
+ Future<void> _initUploadQueue(String sessionId) async {
627
+ await _uploadQueue?.dispose();
628
+ final support = await getApplicationSupportDirectory();
629
+ final dir = Directory('${support.path}/recording-pending/$sessionId');
630
+ _uploadQueue = DiskChunkUploadQueue(
631
+ directory: dir,
632
+ uploader: _performChunkUpload,
633
+ logger: (event, {Map<String, Object?> data = const <String, Object?>{}, Object? error}) =>
634
+ _log(event, data: data, error: error),
635
+ );
636
+ }
637
+
620
638
  Future<void> _ensureDesktopPermissions() async {
621
639
  final micGranted = await _requestMicPermission();
622
640
  final systemGranted = await _requestSystemPermission();
@@ -650,12 +668,25 @@ class DesktopRecordingBridge extends RecordingBridge {
650
668
  Future<bool> _requestSystemPermission() async {
651
669
  try {
652
670
  final granted = await _systemCapture.requestPermissions();
671
+ _systemPermissionErrorMessage = null;
653
672
  _status = _status.copyWith(
654
673
  systemAudioPermission: granted
655
674
  ? RecordingPermissionState.granted
656
675
  : RecordingPermissionState.denied,
657
676
  );
658
677
  return granted;
678
+ } on MissingPluginException catch (error, stackTrace) {
679
+ // The system-audio plugin is only registered where capture is supported
680
+ // (macOS 13+ via ScreenCaptureKit). Surface that instead of a misleading
681
+ // "grant permission" prompt the user can never satisfy.
682
+ _systemPermissionErrorMessage =
683
+ 'System audio capture is unavailable on this OS version. On macOS it '
684
+ 'requires macOS 13 (Ventura) or later.';
685
+ _log('system_permission.unavailable', error: error, stackTrace: stackTrace);
686
+ _status = _status.copyWith(
687
+ systemAudioPermission: RecordingPermissionState.unknown,
688
+ );
689
+ return false;
659
690
  } catch (error) {
660
691
  _status = _status.copyWith(
661
692
  systemAudioPermission: _permissionStateFromError(error),
@@ -746,32 +777,27 @@ class DesktopRecordingBridge extends RecordingBridge {
746
777
  _nextSequenceBySource[sourceKey] = sequence + 1;
747
778
  _lastEndMsBySource[sourceKey] = endMs;
748
779
 
749
- final previous = _uploadQueueBySource[sourceKey] ?? Future<void>.value();
750
- _uploadQueueBySource[sourceKey] = previous
751
- // Keep the queue moving even if a previous upload failed.
752
- .catchError((Object _, StackTrace __) {})
753
- .then((_) async {
754
- try {
755
- await _uploadChunk(
756
- sourceKey: sourceKey,
757
- sequence: sequence,
758
- startMs: startMs,
759
- endMs: endMs,
760
- bytes: _wrapPcmAsWav(pcmBytes),
761
- );
762
- } catch (error, stackTrace) {
763
- _handleRuntimeError('$sourceKey.upload', error, stackTrace);
764
- }
765
- });
780
+ final queue = _uploadQueue;
781
+ if (queue == null) {
782
+ return;
783
+ }
784
+ // Persist + enqueue. The queue uploads in order and retries until the chunk
785
+ // is confirmed stored, so a dropped upload no longer loses that audio.
786
+ unawaited(
787
+ queue.enqueue(
788
+ PendingChunk(
789
+ sourceKey: sourceKey,
790
+ sequence: sequence,
791
+ startMs: startMs,
792
+ endMs: endMs,
793
+ mimeType: 'audio/wav',
794
+ bytes: _wrapPcmAsWav(pcmBytes),
795
+ ),
796
+ ),
797
+ );
766
798
  }
767
799
 
768
- Future<void> _uploadChunk({
769
- required String sourceKey,
770
- required int sequence,
771
- required int startMs,
772
- required int endMs,
773
- required Uint8List bytes,
774
- }) async {
800
+ Future<void> _performChunkUpload(PendingChunk chunk) async {
775
801
  final sessionId = _sessionId;
776
802
  final baseUrl = _baseUrl;
777
803
  if (sessionId == null || baseUrl == null || baseUrl.trim().isEmpty) {
@@ -783,49 +809,31 @@ class DesktopRecordingBridge extends RecordingBridge {
783
809
  final uri = Uri.parse(
784
810
  '${baseUrl.replaceFirst(RegExp(r'/$'), '')}/api/recordings/$sessionId/chunks',
785
811
  );
812
+ final headers = <String, String>{
813
+ 'Content-Type': chunk.mimeType,
814
+ 'X-Recording-Source-Key': chunk.sourceKey,
815
+ 'X-Recording-Sequence': '${chunk.sequence}',
816
+ 'X-Recording-Start-Ms': '${chunk.startMs}',
817
+ 'X-Recording-End-Ms': '${chunk.endMs}',
818
+ if ((_sessionCookie ?? '').trim().isNotEmpty)
819
+ 'Cookie': _sessionCookie!.trim(),
820
+ };
786
821
 
787
- for (var attempt = 0; attempt < 3; attempt += 1) {
788
- try {
789
- final headers = <String, String>{
790
- 'Content-Type': 'audio/wav',
791
- 'X-Recording-Source-Key': sourceKey,
792
- 'X-Recording-Sequence': '$sequence',
793
- 'X-Recording-Start-Ms': '$startMs',
794
- 'X-Recording-End-Ms': '$endMs',
795
- if ((_sessionCookie ?? '').trim().isNotEmpty)
796
- 'Cookie': _sessionCookie!.trim(),
797
- };
798
- final response = await _httpClient
799
- .post(uri, headers: headers, body: bytes)
800
- .timeout(const Duration(seconds: 20));
801
- if (response.statusCode < 200 || response.statusCode >= 300) {
802
- throw RecordingBridgeException(
803
- 'Chunk upload failed with status ${response.statusCode}.',
804
- );
805
- }
806
- return;
807
- } on TimeoutException catch (error, stackTrace) {
808
- _log(
809
- 'upload.timeout',
810
- data: <String, Object?>{
811
- 'sourceKey': sourceKey,
812
- 'sequence': sequence,
813
- 'attempt': attempt + 1,
814
- },
815
- error: error,
816
- stackTrace: stackTrace,
822
+ try {
823
+ final response = await _httpClient
824
+ .post(uri, headers: headers, body: chunk.bytes)
825
+ .timeout(const Duration(seconds: 20));
826
+ if (response.statusCode < 200 || response.statusCode >= 300) {
827
+ throw RecordingBridgeException(
828
+ 'Chunk upload failed with status ${response.statusCode}.',
817
829
  );
818
- _httpClient.close();
819
- _httpClient = http.Client();
820
- if (attempt == 2) {
821
- throw const RecordingBridgeException('Chunk upload timed out.');
822
- }
823
- } catch (error) {
824
- if (attempt == 2) {
825
- rethrow;
826
- }
827
- await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
828
830
  }
831
+ } on TimeoutException {
832
+ // A stuck socket can wedge subsequent requests; rebuild the client so the
833
+ // queue's retry gets a fresh connection.
834
+ _httpClient.close();
835
+ _httpClient = http.Client();
836
+ rethrow;
829
837
  }
830
838
  }
831
839
 
@@ -873,7 +881,6 @@ class DesktopRecordingBridge extends RecordingBridge {
873
881
  _pcmBuffers[sourceKey] = <int>[];
874
882
  _nextSequenceBySource[sourceKey] = 0;
875
883
  _lastEndMsBySource[sourceKey] = 0;
876
- _uploadQueueBySource[sourceKey] = Future<void>.value();
877
884
  }
878
885
  }
879
886
 
@@ -930,7 +937,8 @@ class DesktopRecordingBridge extends RecordingBridge {
930
937
  if (!micGranted) {
931
938
  return 'Grant microphone permission before starting desktop recording.';
932
939
  }
933
- return 'Grant system audio permission before starting desktop recording.';
940
+ return _systemPermissionErrorMessage ??
941
+ 'Grant system audio permission before starting desktop recording.';
934
942
  }
935
943
 
936
944
  Future<void> _openPlatformSettings({
@@ -7,6 +7,7 @@ import 'dart:typed_data';
7
7
 
8
8
  import 'diagnostics_logger.dart';
9
9
  import 'recording_bridge.dart';
10
+ import 'recording_chunk_queue.dart';
10
11
 
11
12
  RecordingBridge createPlatformRecordingBridge() => WebRecordingBridge();
12
13
 
@@ -18,13 +19,18 @@ class WebRecordingBridge extends RecordingBridge {
18
19
  );
19
20
 
20
21
  html.MediaStream? _displayStream;
22
+ html.MediaStream? _systemAudioStream;
21
23
  html.MediaStream? _microphoneStream;
22
24
  html.MediaRecorder? _screenRecorder;
25
+ html.MediaRecorder? _systemAudioRecorder;
23
26
  html.MediaRecorder? _microphoneRecorder;
24
27
  final Map<String, int> _nextSequenceBySource = <String, int>{};
25
28
  final Map<String, int> _lastEndMsBySource = <String, int>{};
26
- final Map<String, Future<void>> _uploadQueueBySource =
27
- <String, Future<void>>{};
29
+ // One durable, retry-until-success queue per source so a failed upload is
30
+ // re-sent instead of silently dropped, and a stall in one source does not
31
+ // block the others.
32
+ final Map<String, MemoryChunkUploadQueue> _uploadQueues =
33
+ <String, MemoryChunkUploadQueue>{};
28
34
  Stopwatch? _stopwatch;
29
35
  String? _baseUrl;
30
36
  String? _sessionId;
@@ -119,22 +125,31 @@ class WebRecordingBridge extends RecordingBridge {
119
125
  );
120
126
  }
121
127
 
128
+ // Split the display capture: video-only feeds the screen-analysis source,
129
+ // while the shared system/tab audio gets its own transcribable source so
130
+ // remote participants are captured in the transcript (the mic alone does
131
+ // not reliably pick up other speakers, e.g. when wearing headphones).
132
+ final screenVideoStream = html.MediaStream();
133
+ for (final track in displayStream.getVideoTracks()) {
134
+ screenVideoStream.addTrack(track);
135
+ }
136
+ final systemAudioStream = html.MediaStream();
137
+ for (final track in displayStream.getAudioTracks()) {
138
+ systemAudioStream.addTrack(track);
139
+ }
140
+
122
141
  _baseUrl = baseUrl;
123
142
  _sessionId = sessionId;
124
143
  _displayStream = displayStream;
144
+ _systemAudioStream = systemAudioStream;
125
145
  _microphoneStream = microphoneStream;
126
146
  _nextSequenceBySource
127
147
  ..clear()
128
- ..addAll(<String, int>{'screen': 0, 'microphone': 0});
148
+ ..addAll(<String, int>{'screen': 0, 'system': 0, 'microphone': 0});
129
149
  _lastEndMsBySource
130
150
  ..clear()
131
- ..addAll(<String, int>{'screen': 0, 'microphone': 0});
132
- _uploadQueueBySource
133
- ..clear()
134
- ..addAll(<String, Future<void>>{
135
- 'screen': Future<void>.value(),
136
- 'microphone': Future<void>.value(),
137
- });
151
+ ..addAll(<String, int>{'screen': 0, 'system': 0, 'microphone': 0});
152
+ _initUploadQueues(<String>['screen', 'system', 'microphone']);
138
153
  _stopwatch = Stopwatch()..start();
139
154
 
140
155
  final screenMimeType = _pickMimeType(<String>[
@@ -142,13 +157,13 @@ class WebRecordingBridge extends RecordingBridge {
142
157
  'video/webm;codecs=vp8,opus',
143
158
  'video/webm',
144
159
  ]);
145
- final micMimeType = _pickMimeType(<String>[
160
+ final audioMimeType = _pickMimeType(<String>[
146
161
  'audio/webm;codecs=opus',
147
162
  'audio/webm',
148
163
  ]);
149
164
 
150
165
  _screenRecorder = html.MediaRecorder(
151
- displayStream,
166
+ screenVideoStream,
152
167
  screenMimeType == null
153
168
  ? null
154
169
  : <String, String>{'mimeType': screenMimeType},
@@ -165,17 +180,27 @@ class WebRecordingBridge extends RecordingBridge {
165
180
  sourceKey: 'screen',
166
181
  mimeType: screenMimeType ?? 'video/webm',
167
182
  );
183
+ _systemAudioRecorder = html.MediaRecorder(
184
+ systemAudioStream,
185
+ audioMimeType == null ? null : <String, String>{'mimeType': audioMimeType},
186
+ );
187
+ _bindRecorder(
188
+ recorder: _systemAudioRecorder!,
189
+ sourceKey: 'system',
190
+ mimeType: audioMimeType ?? 'audio/webm',
191
+ );
168
192
  _microphoneRecorder = html.MediaRecorder(
169
193
  microphoneStream,
170
- micMimeType == null ? null : <String, String>{'mimeType': micMimeType},
194
+ audioMimeType == null ? null : <String, String>{'mimeType': audioMimeType},
171
195
  );
172
196
  _bindRecorder(
173
197
  recorder: _microphoneRecorder!,
174
198
  sourceKey: 'microphone',
175
- mimeType: micMimeType ?? 'audio/webm',
199
+ mimeType: audioMimeType ?? 'audio/webm',
176
200
  );
177
201
 
178
202
  _screenRecorder!.start(4000);
203
+ _systemAudioRecorder!.start(4000);
179
204
  _microphoneRecorder!.start(4000);
180
205
  _status = _status.copyWith(
181
206
  active: true,
@@ -189,7 +214,8 @@ class WebRecordingBridge extends RecordingBridge {
189
214
  data: <String, Object?>{
190
215
  'sessionId': sessionId,
191
216
  'screenMimeType': screenMimeType ?? 'video/webm',
192
- 'micMimeType': micMimeType ?? 'audio/webm',
217
+ 'systemMimeType': audioMimeType ?? 'audio/webm',
218
+ 'micMimeType': audioMimeType ?? 'audio/webm',
193
219
  },
194
220
  );
195
221
  notifyListeners();
@@ -261,11 +287,7 @@ class WebRecordingBridge extends RecordingBridge {
261
287
  _lastEndMsBySource
262
288
  ..clear()
263
289
  ..addAll(<String, int>{'microphone': 0});
264
- _uploadQueueBySource
265
- ..clear()
266
- ..addAll(<String, Future<void>>{
267
- 'microphone': Future<void>.value(),
268
- });
290
+ _initUploadQueues(<String>['microphone']);
269
291
  _stopwatch = Stopwatch()..start();
270
292
 
271
293
  final micMimeType = _pickMimeType(<String>[
@@ -404,7 +426,9 @@ class WebRecordingBridge extends RecordingBridge {
404
426
  );
405
427
  try {
406
428
  await _stopRecorders();
407
- await Future.wait(_uploadQueueBySource.values);
429
+ await Future.wait(
430
+ _uploadQueues.values.map((queue) => queue.flush()),
431
+ );
408
432
  await _disposeStreams();
409
433
  _status = _status.copyWith(
410
434
  active: false,
@@ -432,6 +456,30 @@ class WebRecordingBridge extends RecordingBridge {
432
456
  }
433
457
  }
434
458
 
459
+ void _initUploadQueues(List<String> sourceKeys) {
460
+ for (final queue in _uploadQueues.values) {
461
+ unawaited(queue.dispose());
462
+ }
463
+ _uploadQueues
464
+ ..clear()
465
+ ..addEntries(
466
+ sourceKeys.map(
467
+ (sourceKey) => MapEntry<String, MemoryChunkUploadQueue>(
468
+ sourceKey,
469
+ MemoryChunkUploadQueue(
470
+ uploader: _performChunkUpload,
471
+ logger:
472
+ (
473
+ event, {
474
+ Map<String, Object?> data = const <String, Object?>{},
475
+ Object? error,
476
+ }) => _log(event, data: data, error: error),
477
+ ),
478
+ ),
479
+ ),
480
+ );
481
+ }
482
+
435
483
  void _bindRecorder({
436
484
  required html.MediaRecorder recorder,
437
485
  required String sourceKey,
@@ -447,49 +495,42 @@ class WebRecordingBridge extends RecordingBridge {
447
495
  _lastEndMsBySource[sourceKey] = endMs;
448
496
  final sequence = _nextSequenceBySource[sourceKey] ?? 0;
449
497
  _nextSequenceBySource[sourceKey] = sequence + 1;
450
- final upload = (_uploadQueueBySource[sourceKey] ?? Future<void>.value())
451
- .then(
452
- (_) => _uploadChunk(
498
+ final queue = _uploadQueues[sourceKey];
499
+ if (queue == null) {
500
+ return;
501
+ }
502
+ // Read the blob bytes, then hand the chunk to the durable queue, which
503
+ // uploads and retries until the server confirms it (so a transient
504
+ // failure no longer drops the chunk or stalls the rest of the source).
505
+ unawaited(() async {
506
+ try {
507
+ final bytes = await _blobToBytes(blob);
508
+ queue.enqueue(
509
+ PendingChunk(
453
510
  sourceKey: sourceKey,
454
511
  sequence: sequence,
455
512
  startMs: startMs,
456
513
  endMs: endMs,
457
- blob: blob,
458
514
  mimeType: mimeType,
515
+ bytes: bytes,
459
516
  ),
460
517
  );
461
- _uploadQueueBySource[sourceKey] = upload.catchError((
462
- Object error,
463
- StackTrace stackTrace,
464
- ) {
465
- final stackText = stackTrace.toString();
466
- _status = _status.copyWith(
467
- errorMessage: stackText.isNotEmpty ? stackText : error.toString(),
468
- );
469
- _log(
470
- 'chunk.upload_queue.error',
471
- data: <String, Object?>{
472
- 'sourceKey': sourceKey,
473
- 'sequence': sequence,
474
- 'stackTrace': stackText,
475
- },
476
- error: error,
477
- stackTrace: stackTrace,
478
- );
479
- notifyListeners();
480
- Error.throwWithStackTrace(error, stackTrace);
481
- });
518
+ } catch (error, stackTrace) {
519
+ _log(
520
+ 'chunk.read.failed',
521
+ data: <String, Object?>{
522
+ 'sourceKey': sourceKey,
523
+ 'sequence': sequence,
524
+ },
525
+ error: error,
526
+ stackTrace: stackTrace,
527
+ );
528
+ }
529
+ }());
482
530
  });
483
531
  }
484
532
 
485
- Future<void> _uploadChunk({
486
- required String sourceKey,
487
- required int sequence,
488
- required int startMs,
489
- required int endMs,
490
- required html.Blob blob,
491
- required String mimeType,
492
- }) async {
533
+ Future<void> _performChunkUpload(PendingChunk chunk) async {
493
534
  final sessionId = _sessionId;
494
535
  final baseUrl = _baseUrl;
495
536
  if (sessionId == null || baseUrl == null) {
@@ -497,38 +538,38 @@ class WebRecordingBridge extends RecordingBridge {
497
538
  'Recording session is not initialized.',
498
539
  );
499
540
  }
500
- final bytes = await _blobToBytes(blob);
501
541
  final uri = _resolveUri(baseUrl, '/api/recordings/$sessionId/chunks');
542
+ final headers = <String, String>{
543
+ 'Content-Type': chunk.mimeType,
544
+ 'X-Recording-Source-Key': chunk.sourceKey,
545
+ 'X-Recording-Sequence': '${chunk.sequence}',
546
+ 'X-Recording-Start-Ms': '${chunk.startMs}',
547
+ 'X-Recording-End-Ms': '${chunk.endMs}',
548
+ };
502
549
  _log(
503
- 'chunk.upload.request',
550
+ 'chunk.upload.attempt',
504
551
  data: <String, Object?>{
505
552
  'sessionId': sessionId,
506
- 'sourceKey': sourceKey,
507
- 'sequence': sequence,
508
- 'startMs': startMs,
509
- 'endMs': endMs,
510
- 'size': bytes.length,
511
- 'mimeType': mimeType,
553
+ 'sourceKey': chunk.sourceKey,
554
+ 'sequence': chunk.sequence,
555
+ 'size': chunk.bytes.length,
556
+ 'headers': _filterHeadersForLog(headers),
512
557
  },
513
558
  );
514
- await _requestWithRetry(
559
+ await html.HttpRequest.request(
515
560
  uri.toString(),
516
- headers: <String, String>{
517
- 'Content-Type': mimeType,
518
- 'X-Recording-Source-Key': sourceKey,
519
- 'X-Recording-Sequence': '$sequence',
520
- 'X-Recording-Start-Ms': '$startMs',
521
- 'X-Recording-End-Ms': '$endMs',
522
- },
523
- body: bytes,
561
+ method: 'POST',
562
+ sendData: chunk.bytes,
563
+ requestHeaders: headers,
564
+ withCredentials: true,
524
565
  );
525
566
  _log(
526
567
  'chunk.upload.done',
527
568
  data: <String, Object?>{
528
569
  'sessionId': sessionId,
529
- 'sourceKey': sourceKey,
530
- 'sequence': sequence,
531
- 'size': bytes.length,
570
+ 'sourceKey': chunk.sourceKey,
571
+ 'sequence': chunk.sequence,
572
+ 'size': chunk.bytes.length,
532
573
  },
533
574
  );
534
575
  }
@@ -551,6 +592,11 @@ class WebRecordingBridge extends RecordingBridge {
551
592
  futures.add(_waitForStop(_screenRecorder!));
552
593
  _screenRecorder!.stop();
553
594
  }
595
+ if (_systemAudioRecorder != null &&
596
+ _systemAudioRecorder!.state != 'inactive') {
597
+ futures.add(_waitForStop(_systemAudioRecorder!));
598
+ _systemAudioRecorder!.stop();
599
+ }
554
600
  if (_microphoneRecorder != null &&
555
601
  _microphoneRecorder!.state != 'inactive') {
556
602
  futures.add(_waitForStop(_microphoneRecorder!));
@@ -563,55 +609,22 @@ class WebRecordingBridge extends RecordingBridge {
563
609
  await _displayEndedSub?.cancel();
564
610
  _displayEndedSub = null;
565
611
  _displayStream?.getTracks().forEach((track) => track.stop());
612
+ _systemAudioStream?.getTracks().forEach((track) => track.stop());
566
613
  _microphoneStream?.getTracks().forEach((track) => track.stop());
567
614
  _displayStream = null;
615
+ _systemAudioStream = null;
568
616
  _microphoneStream = null;
569
617
  _screenRecorder = null;
618
+ _systemAudioRecorder = null;
570
619
  _microphoneRecorder = null;
620
+ for (final queue in _uploadQueues.values) {
621
+ await queue.dispose();
622
+ }
623
+ _uploadQueues.clear();
571
624
  _stopwatch?.stop();
572
625
  _stopwatch = null;
573
626
  }
574
627
 
575
- Future<void> _requestWithRetry(
576
- String url, {
577
- required Map<String, String> headers,
578
- required Uint8List body,
579
- }) async {
580
- Object? lastError;
581
- for (var attempt = 0; attempt < 3; attempt += 1) {
582
- try {
583
- _log(
584
- 'chunk.upload.attempt',
585
- data: <String, Object?>{
586
- 'url': url,
587
- 'attempt': attempt + 1,
588
- 'size': body.length,
589
- 'headers': _filterHeadersForLog(headers),
590
- },
591
- );
592
- await html.HttpRequest.request(
593
- url,
594
- method: 'POST',
595
- sendData: body,
596
- requestHeaders: headers,
597
- withCredentials: true,
598
- );
599
- return;
600
- } catch (error) {
601
- lastError = error;
602
- _log(
603
- 'chunk.upload.attempt_failed',
604
- data: <String, Object?>{'url': url, 'attempt': attempt + 1},
605
- error: error,
606
- );
607
- await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
608
- }
609
- }
610
- throw RecordingBridgeException(
611
- 'Could not upload a recording chunk: ${lastError ?? 'unknown error'}',
612
- );
613
- }
614
-
615
628
  Map<String, String> _filterHeadersForLog(Map<String, String> headers) {
616
629
  final filtered = <String, String>{};
617
630
  headers.forEach((key, value) {