@agent-native/core 0.84.18 → 0.84.21

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 (157) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +21 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/caller-auth.ts +37 -8
  5. package/corpus/core/src/a2a/client.ts +178 -67
  6. package/corpus/core/src/agent/production-agent.ts +19 -3
  7. package/corpus/core/src/agent/run-store.ts +180 -13
  8. package/corpus/core/src/agent/types.ts +1 -1
  9. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  10. package/corpus/core/src/cli/recap.ts +43 -34
  11. package/corpus/core/src/client/AssistantChat.tsx +49 -0
  12. package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
  13. package/corpus/core/src/client/active-run-state.ts +30 -0
  14. package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
  15. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +41 -3
  16. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +133 -0
  17. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +2 -2
  18. package/corpus/core/src/client/i18n.tsx +10 -5
  19. package/corpus/core/src/client/sse-event-processor.ts +147 -5
  20. package/corpus/core/src/extensions/html-shell.ts +17 -3
  21. package/corpus/core/src/extensions/theme.ts +39 -0
  22. package/corpus/core/src/file-upload/builder.ts +35 -14
  23. package/corpus/core/src/file-upload/types.ts +3 -0
  24. package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
  25. package/corpus/core/src/mcp/builtin-tools.ts +3 -0
  26. package/corpus/core/src/mcp/org-directory.ts +66 -20
  27. package/corpus/core/src/scripts/call-agent.ts +7 -2
  28. package/corpus/templates/analytics/actions/provider-api-request.ts +8 -1
  29. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -1
  30. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +91 -33
  31. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
  32. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
  33. package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
  34. package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
  35. package/corpus/templates/clips/actions/create-recording.ts +7 -2
  36. package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
  37. package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
  38. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
  39. package/corpus/templates/clips/actions/lib/loom-video.ts +16 -0
  40. package/corpus/templates/clips/app/components/player/scrubber.tsx +2 -2
  41. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
  42. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
  43. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +65 -1
  44. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
  45. package/corpus/templates/clips/app/routes/record.tsx +48 -8
  46. package/corpus/templates/clips/changelog/2026-07-01-chrome-recording-uploads-now-serialize-chunks-to-avoid-resum.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-01-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
  55. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  56. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
  57. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +94 -29
  58. package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
  59. package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
  60. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +14 -1
  61. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
  62. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
  63. package/corpus/templates/clips/shared/finalize-recovery.ts +242 -0
  64. package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
  65. package/corpus/templates/plan/app/global.css +1 -1
  66. package/dist/a2a/caller-auth.d.ts +1 -0
  67. package/dist/a2a/caller-auth.d.ts.map +1 -1
  68. package/dist/a2a/caller-auth.js +34 -8
  69. package/dist/a2a/caller-auth.js.map +1 -1
  70. package/dist/a2a/client.d.ts +4 -0
  71. package/dist/a2a/client.d.ts.map +1 -1
  72. package/dist/a2a/client.js +148 -58
  73. package/dist/a2a/client.js.map +1 -1
  74. package/dist/agent/production-agent.d.ts.map +1 -1
  75. package/dist/agent/production-agent.js +19 -4
  76. package/dist/agent/production-agent.js.map +1 -1
  77. package/dist/agent/run-store.d.ts +19 -6
  78. package/dist/agent/run-store.d.ts.map +1 -1
  79. package/dist/agent/run-store.js +163 -13
  80. package/dist/agent/run-store.js.map +1 -1
  81. package/dist/agent/types.d.ts +1 -0
  82. package/dist/agent/types.d.ts.map +1 -1
  83. package/dist/agent/types.js.map +1 -1
  84. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  85. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  86. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  87. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  88. package/dist/cli/recap.d.ts +3 -3
  89. package/dist/cli/recap.d.ts.map +1 -1
  90. package/dist/cli/recap.js +41 -34
  91. package/dist/cli/recap.js.map +1 -1
  92. package/dist/client/AssistantChat.d.ts +2 -0
  93. package/dist/client/AssistantChat.d.ts.map +1 -1
  94. package/dist/client/AssistantChat.js +36 -2
  95. package/dist/client/AssistantChat.js.map +1 -1
  96. package/dist/client/PoweredByBadge.d.ts +9 -3
  97. package/dist/client/PoweredByBadge.d.ts.map +1 -1
  98. package/dist/client/PoweredByBadge.js +47 -15
  99. package/dist/client/PoweredByBadge.js.map +1 -1
  100. package/dist/client/active-run-state.d.ts +3 -0
  101. package/dist/client/active-run-state.d.ts.map +1 -1
  102. package/dist/client/active-run-state.js +24 -0
  103. package/dist/client/active-run-state.js.map +1 -1
  104. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  105. package/dist/client/agent-chat-adapter.js +5 -1
  106. package/dist/client/agent-chat-adapter.js.map +1 -1
  107. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  108. package/dist/client/extensions/EmbeddedExtension.js +32 -3
  109. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  110. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  111. package/dist/client/extensions/ExtensionViewer.js +59 -2
  112. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  113. package/dist/client/extensions/ExtensionsSidebarSection.js +2 -2
  114. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  115. package/dist/client/i18n.d.ts +1 -1
  116. package/dist/client/i18n.d.ts.map +1 -1
  117. package/dist/client/i18n.js +6 -2
  118. package/dist/client/i18n.js.map +1 -1
  119. package/dist/client/sse-event-processor.d.ts +2 -0
  120. package/dist/client/sse-event-processor.d.ts.map +1 -1
  121. package/dist/client/sse-event-processor.js +121 -5
  122. package/dist/client/sse-event-processor.js.map +1 -1
  123. package/dist/collab/awareness.d.ts +2 -2
  124. package/dist/collab/awareness.d.ts.map +1 -1
  125. package/dist/collab/routes.d.ts +1 -1
  126. package/dist/extensions/html-shell.d.ts.map +1 -1
  127. package/dist/extensions/html-shell.js +17 -3
  128. package/dist/extensions/html-shell.js.map +1 -1
  129. package/dist/extensions/theme.d.ts +9 -0
  130. package/dist/extensions/theme.d.ts.map +1 -1
  131. package/dist/extensions/theme.js +38 -0
  132. package/dist/extensions/theme.js.map +1 -1
  133. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  134. package/dist/file-upload/builder.d.ts.map +1 -1
  135. package/dist/file-upload/builder.js +22 -8
  136. package/dist/file-upload/builder.js.map +1 -1
  137. package/dist/file-upload/types.d.ts +5 -1
  138. package/dist/file-upload/types.d.ts.map +1 -1
  139. package/dist/file-upload/types.js.map +1 -1
  140. package/dist/integrations/a2a-continuation-processor.js +43 -18
  141. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  142. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  143. package/dist/mcp/builtin-tools.js +3 -0
  144. package/dist/mcp/builtin-tools.js.map +1 -1
  145. package/dist/mcp/org-directory.d.ts.map +1 -1
  146. package/dist/mcp/org-directory.js +58 -20
  147. package/dist/mcp/org-directory.js.map +1 -1
  148. package/dist/notifications/routes.d.ts +3 -3
  149. package/dist/observability/routes.d.ts +6 -6
  150. package/dist/progress/routes.d.ts +1 -1
  151. package/dist/resources/handlers.d.ts +3 -3
  152. package/dist/scripts/call-agent.d.ts.map +1 -1
  153. package/dist/scripts/call-agent.js +2 -2
  154. package/dist/scripts/call-agent.js.map +1 -1
  155. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  156. package/dist/server/transcribe-voice.d.ts +1 -1
  157. package/package.json +1 -1
@@ -33,18 +33,36 @@ const STORAGE_SETUP_REQUIRED_MESSAGE =
33
33
  "Connect storage to finish saving this clip: Builder.io (free tier storage + AI) or S3-compatible storage.";
34
34
  const STORAGE_SETUP_FAILURE_RE =
35
35
  /video storage is not connected|no video storage configured|file upload provider|storage provider|connect builder|s3-compatible/i;
36
+ const CHUNK_UPLOAD_MAX_ATTEMPTS = 3;
37
+ const RETRYABLE_CHUNK_UPLOAD_STATUSES = new Set([
38
+ 408, 425, 429, 500, 502, 503, 504,
39
+ ]);
36
40
 
37
41
  function isStorageSetupFailureMessage(message: string | null | undefined) {
38
42
  return STORAGE_SETUP_FAILURE_RE.test(message ?? "");
39
43
  }
40
44
 
45
+ function isRetryableChunkUploadStatus(status: number): boolean {
46
+ return RETRYABLE_CHUNK_UPLOAD_STATUSES.has(status);
47
+ }
48
+
49
+ function retryDelayMs(attempt: number): number {
50
+ return attempt === 1 ? 500 : 1500;
51
+ }
52
+
53
+ function waitForRetry(ms: number): Promise<void> {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }
56
+
41
57
  function isFinalUploadRecoveryCandidate(error: Error): boolean {
42
58
  const tagged = error as {
59
+ finalUploadRecoveryAttempted?: boolean;
43
60
  finalUpload?: boolean;
44
61
  status?: number;
45
62
  storageSetupRequired?: boolean;
46
63
  };
47
64
  if (!tagged.finalUpload || tagged.storageSetupRequired) return false;
65
+ if (tagged.finalUploadRecoveryAttempted) return false;
48
66
  if (tagged.status === 413) return false;
49
67
  return !/too large|exceeds.*limit|chunk too large/i.test(error.message);
50
68
  }
@@ -129,6 +147,7 @@ type ActiveRecording = {
129
147
  sourceStreams: MediaStream[];
130
148
  audioContext: AudioContext | null;
131
149
  chunkIndex: number;
150
+ uploadChain: Promise<void>;
132
151
  uploadPromises: Promise<unknown>[];
133
152
  uploadFailure: Error | null;
134
153
  // Local safety buffer: every recorded blob is kept here (browser-managed,
@@ -448,12 +467,50 @@ async function uploadChunk(
448
467
  if (recording.authToken) {
449
468
  headers.Authorization = `Bearer ${recording.authToken}`;
450
469
  }
451
- const res = await fetch(url, {
452
- method: "POST",
453
- headers,
454
- credentials: "include",
455
- body,
456
- });
470
+ let res: Response | null = null;
471
+ let triedFinalUploadRecovery = false;
472
+ for (let attempt = 1; attempt <= CHUNK_UPLOAD_MAX_ATTEMPTS; attempt++) {
473
+ try {
474
+ res = await fetch(url, {
475
+ method: "POST",
476
+ headers,
477
+ credentials: "include",
478
+ body,
479
+ });
480
+ } catch (err) {
481
+ if (attempt >= CHUNK_UPLOAD_MAX_ATTEMPTS) throw err;
482
+ await waitForRetry(retryDelayMs(attempt));
483
+ continue;
484
+ }
485
+
486
+ if (
487
+ !res.ok &&
488
+ attempt < CHUNK_UPLOAD_MAX_ATTEMPTS &&
489
+ isRetryableChunkUploadStatus(res.status)
490
+ ) {
491
+ if (extra.isFinal && res.status === 504) {
492
+ triedFinalUploadRecovery = true;
493
+ await res.text().catch(() => "");
494
+ const recovered = await waitForReadyRecordingAfterFinalizeError({
495
+ uploadUrl: recording.uploadUrl,
496
+ recordingId: recording.recordingId,
497
+ authToken: recording.authToken,
498
+ });
499
+ if (recovered) return recovered;
500
+ break;
501
+ }
502
+ await res.text().catch(() => "");
503
+ await waitForRetry(retryDelayMs(attempt));
504
+ continue;
505
+ }
506
+
507
+ break;
508
+ }
509
+
510
+ if (!res) {
511
+ throw new Error("Upload failed: no response");
512
+ }
513
+
457
514
  const text = await res.text().catch(() => "");
458
515
  let data: UploadResult = {};
459
516
  if (text) {
@@ -481,9 +538,11 @@ async function uploadChunk(
481
538
  `Upload failed (${res.status}): ${text || res.statusText}`,
482
539
  );
483
540
  const uploadError = error as {
541
+ finalUploadRecoveryAttempted?: boolean;
484
542
  status?: number;
485
543
  storageSetupRequired?: boolean;
486
544
  };
545
+ uploadError.finalUploadRecoveryAttempted = triedFinalUploadRecovery;
487
546
  uploadError.status = res.status;
488
547
  uploadError.storageSetupRequired = storageSetupRequired;
489
548
  captureExtensionError(error, {
@@ -795,6 +854,7 @@ async function begin(message: BeginMessage): Promise<{
795
854
  ],
796
855
  audioContext: mixedAudio.audioContext,
797
856
  chunkIndex: 0,
857
+ uploadChain: Promise.resolve(),
798
858
  uploadPromises: [],
799
859
  uploadFailure: null,
800
860
  recordedBlobs: [],
@@ -837,30 +897,35 @@ async function begin(message: BeginMessage): Promise<{
837
897
  // rejected promise that surfaces as an "Uncaught (in promise)" error (bad
838
898
  // look in a Chrome Web Store review). finalizeStop reads recording.upload-
839
899
  // Failure and surfaces it through the normal error path instead.
840
- const upload = (
841
- recording.uploadMode === "streaming"
842
- ? uploadStreamingBlob(recording, event.data)
843
- : uploadBlobInSlices(recording, event.data)
844
- ).catch((err) => {
845
- recording.uploadFailure =
846
- err instanceof Error ? err : new Error(String(err));
847
- captureExtensionError(recording.uploadFailure, {
848
- tags: {
849
- surface: "offscreen",
850
- recordingStep: "dataavailable-upload",
851
- },
852
- extra: {
853
- recordingId: recording.recordingId,
854
- blobBytes: event.data.size,
855
- chunkIndex: recording.chunkIndex,
856
- mimeType: event.data.type || recording.mimeType,
857
- },
858
- });
859
- reportStatus(recording.sessionId, "error", {
860
- error: recording.uploadFailure.message,
900
+ const upload = recording.uploadChain
901
+ .then(() =>
902
+ recording.cancelled || recording.uploadFailure
903
+ ? undefined
904
+ : recording.uploadMode === "streaming"
905
+ ? uploadStreamingBlob(recording, event.data)
906
+ : uploadBlobInSlices(recording, event.data),
907
+ )
908
+ .catch((err) => {
909
+ recording.uploadFailure =
910
+ err instanceof Error ? err : new Error(String(err));
911
+ captureExtensionError(recording.uploadFailure, {
912
+ tags: {
913
+ surface: "offscreen",
914
+ recordingStep: "dataavailable-upload",
915
+ },
916
+ extra: {
917
+ recordingId: recording.recordingId,
918
+ blobBytes: event.data.size,
919
+ chunkIndex: recording.chunkIndex,
920
+ mimeType: event.data.type || recording.mimeType,
921
+ },
922
+ });
923
+ reportStatus(recording.sessionId, "error", {
924
+ error: recording.uploadFailure.message,
925
+ });
926
+ if (recorder.state !== "inactive") recorder.stop();
861
927
  });
862
- if (recorder.state !== "inactive") recorder.stop();
863
- });
928
+ recording.uploadChain = upload.then(() => undefined);
864
929
  recording.uploadPromises.push(upload);
865
930
  });
866
931
 
@@ -51,6 +51,7 @@ import { invoke } from "@tauri-apps/api/core";
51
51
  import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
52
52
  import { open as openExternal } from "@tauri-apps/plugin-shell";
53
53
 
54
+ import { waitForReadyRecordingAfterFinalizeError } from "../../../shared/finalize-recovery";
54
55
  import type { LocalRecordingMode } from "../shared/config";
55
56
  import { createAudioCue, type AudioCue } from "./audio-cue";
56
57
  import { createCameraCompositeStream } from "./camera-composite";
@@ -718,6 +719,28 @@ async function markBrowserRecordingBackupError(
718
719
  });
719
720
  }
720
721
 
722
+ async function recoverReadyRecordingAfterFinalizeError({
723
+ serverUrl,
724
+ recordingId,
725
+ authToken,
726
+ }: {
727
+ serverUrl: string;
728
+ recordingId: string;
729
+ authToken?: string;
730
+ }): Promise<boolean> {
731
+ const recovered = await waitForReadyRecordingAfterFinalizeError({
732
+ uploadUrl: chunkUrl(serverUrl, recordingId, 0, false),
733
+ recordingId,
734
+ authToken,
735
+ preferAuthenticated: true,
736
+ });
737
+ if (!recovered) return false;
738
+ await deleteBrowserRecordingBackup(recordingId).catch((err) => {
739
+ console.warn("[clips-recorder] recovered backup cleanup failed:", err);
740
+ });
741
+ return true;
742
+ }
743
+
721
744
  export async function listBrowserRecordingBackups(): Promise<
722
745
  PendingBrowserRecordingUpload[]
723
746
  > {
@@ -827,8 +850,12 @@ export async function retryBrowserRecordingBackup(input: {
827
850
  );
828
851
  }
829
852
 
830
- await postBackupChunk(
831
- chunkUrl(meta.serverUrl, meta.recordingId, validatedChunks.length, true, {
853
+ const finalChunkUrl = chunkUrl(
854
+ meta.serverUrl,
855
+ meta.recordingId,
856
+ validatedChunks.length,
857
+ true,
858
+ {
832
859
  total: String(totalPosts),
833
860
  mimeType: meta.mimeType,
834
861
  durationMs: String(Math.round(meta.durationMs || 0)),
@@ -836,10 +863,29 @@ export async function retryBrowserRecordingBackup(input: {
836
863
  ...(meta.height ? { height: String(meta.height) } : {}),
837
864
  hasAudio: meta.hasAudio ? "1" : "0",
838
865
  hasCamera: meta.hasCamera ? "1" : "0",
839
- }),
840
- new Blob([], { type: meta.mimeType }),
841
- input.authToken,
866
+ },
842
867
  );
868
+ try {
869
+ await postBackupChunk(
870
+ finalChunkUrl,
871
+ new Blob([], { type: meta.mimeType }),
872
+ input.authToken,
873
+ );
874
+ } catch (err) {
875
+ if (
876
+ await recoverReadyRecordingAfterFinalizeError({
877
+ serverUrl: meta.serverUrl,
878
+ recordingId: meta.recordingId,
879
+ authToken: input.authToken,
880
+ })
881
+ ) {
882
+ return {
883
+ recordingId: meta.recordingId,
884
+ viewUrl: `/r/${meta.recordingId}`,
885
+ };
886
+ }
887
+ throw err;
888
+ }
843
889
 
844
890
  await deleteBrowserRecordingBackup(meta.recordingId);
845
891
  return { recordingId: meta.recordingId, viewUrl: `/r/${meta.recordingId}` };
@@ -1986,6 +2032,22 @@ async function startNativeFullscreenRecording(
1986
2032
  try {
1987
2033
  uploadResult = await uploadPromise;
1988
2034
  } catch (err) {
2035
+ if (
2036
+ await recoverReadyRecordingAfterFinalizeError({
2037
+ serverUrl: params.serverUrl,
2038
+ recordingId: id,
2039
+ authToken: params.authToken,
2040
+ })
2041
+ ) {
2042
+ try {
2043
+ await openExternal(
2044
+ `${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
2045
+ );
2046
+ } catch (openErr) {
2047
+ console.error("[clips-recorder] openExternal failed:", openErr);
2048
+ }
2049
+ return { recordingId: id, viewUrl };
2050
+ }
1989
2051
  await abortRecordingUpload(
1990
2052
  params.serverUrl,
1991
2053
  id,
@@ -3225,6 +3287,26 @@ async function startRecordingInner(
3225
3287
  } catch (err) {
3226
3288
  console.error("[clips-recorder] finalize fetch failed:", err);
3227
3289
  const error = err instanceof Error ? err : new Error(String(err));
3290
+ if (
3291
+ await recoverReadyRecordingAfterFinalizeError({
3292
+ serverUrl: params.serverUrl,
3293
+ recordingId: id,
3294
+ authToken: params.authToken,
3295
+ })
3296
+ ) {
3297
+ const viewUrl = `/r/${id}`;
3298
+ try {
3299
+ await openExternal(
3300
+ `${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
3301
+ );
3302
+ } catch (openErr) {
3303
+ console.error("[clips-recorder] openExternal failed:", openErr);
3304
+ }
3305
+ invoke("hide_finalizing").catch((hideErr) =>
3306
+ console.error("[clips-recorder] hide_finalizing failed:", hideErr),
3307
+ );
3308
+ return { recordingId: id, viewUrl };
3309
+ }
3228
3310
  await markBrowserRecordingBackupError(id, error.message).catch(
3229
3311
  () => {},
3230
3312
  );
@@ -0,0 +1,7 @@
1
+ // Temporary kill switch: streaming resumable uploads caused a spike of
2
+ // recording issues. Set CLIPS_DISABLE_STREAMING_UPLOAD=false to re-enable
3
+ // once the root cause is fixed. Defaults to disabled (streaming off).
4
+ export function isStreamingUploadDisabled(): boolean {
5
+ const flag = process.env.CLIPS_DISABLE_STREAMING_UPLOAD ?? "";
6
+ return flag.toLowerCase() !== "false";
7
+ }
@@ -46,6 +46,7 @@ import {
46
46
  setResumableSession,
47
47
  type StoredResumableSession,
48
48
  } from "../../../../lib/resumable-session.js";
49
+ import { isStreamingUploadDisabled } from "../../../../lib/streaming-upload-mode.js";
49
50
  import {
50
51
  shouldRejectVideoUploadWithoutStorage,
51
52
  STORAGE_SETUP_REQUIRED_REASON,
@@ -174,6 +175,11 @@ export default defineEventHandler(async (event: H3Event) => {
174
175
 
175
176
  // Resumable streaming path — forward chunks directly to the provider.
176
177
  const resumableSession = await getResumableSession(recordingId);
178
+ if (resumableSession && isStreamingUploadDisabled()) {
179
+ console.warn(
180
+ `[chunk] streaming uploads are disabled, but preserving existing resumable session for in-flight recording: ${recordingId}`,
181
+ );
182
+ }
177
183
  if (resumableSession) {
178
184
  return handleResumableChunk(
179
185
  event,
@@ -574,6 +580,13 @@ async function handleResumableChunk(
574
580
  }
575
581
 
576
582
  if (isFinal && bytes.byteLength === 0) {
583
+ if (session.bytesUploaded <= 0) {
584
+ setResponseStatus(event, 400);
585
+ return {
586
+ ok: false,
587
+ error: "Cannot finalize an empty resumable upload",
588
+ };
589
+ }
577
590
  // 0-byte sentinel from the recorder after stop(). All data chunks have
578
591
  // already been PUT to the provider; send Content-Range: bytes */<total>
579
592
  // to close the session before handing off to finalize-recording.
@@ -582,7 +595,7 @@ async function handleResumableChunk(
582
595
  `bytes */${session.bytesUploaded}`,
583
596
  new Uint8Array(0),
584
597
  );
585
- if (!closeRes.ok) {
598
+ if (!closeRes.ok || closeRes.status === 308) {
586
599
  console.error(
587
600
  `[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
588
601
  );
@@ -59,7 +59,10 @@ export default defineEventHandler(async (event: H3Event) => {
59
59
  recording: {
60
60
  id: recording.id,
61
61
  status: recording.status,
62
- videoUrl: resolvePlayerVideoUrl(recording, { appPath }),
62
+ videoUrl: resolvePlayerVideoUrl(recording, {
63
+ appPath,
64
+ proxyRemoteMedia: true,
65
+ }),
63
66
  durationMs: recording.durationMs,
64
67
  width: recording.width,
65
68
  height: recording.height,
@@ -86,6 +86,8 @@ const PROXIED_HEADER_NAMES = [
86
86
  const PROVIDER_MEDIA_FETCH_TIMEOUT_MS = 30_000;
87
87
  const PROTECTED_MEDIA_ACCESS_TTL_SECONDS = 6 * 60 * 60;
88
88
  const PROTECTED_MEDIA_COOKIE_PREFIX = "clips_media_";
89
+ const COMPRESSED_BUILDER_MEDIA_MISS_TTL_MS = 5_000;
90
+ const compressedBuilderMediaMisses = new Map<string, number>();
89
91
 
90
92
  function appPath(path: string): string {
91
93
  if (!path.startsWith("/")) return path;
@@ -145,6 +147,82 @@ function isRecursiveVideoRouteUrl(value: string, recordingId: string): boolean {
145
147
  }
146
148
  }
147
149
 
150
+ function compressedBuilderMediaUrl(sourceUrl: string): string | null {
151
+ try {
152
+ const url = new URL(sourceUrl);
153
+ if (!/^cdn(?:-qa)?\.builder\.io$/i.test(url.hostname)) return null;
154
+ if (url.searchParams.get("optimized") === "true") return null;
155
+
156
+ let objectPath: string | null = null;
157
+ if (url.pathname.startsWith("/o/")) {
158
+ objectPath = decodeURIComponent(url.pathname.slice("/o/".length));
159
+ } else if (url.pathname.startsWith("/api/v1/file/")) {
160
+ objectPath = decodeURIComponent(
161
+ url.pathname.slice("/api/v1/file/".length),
162
+ );
163
+ }
164
+ if (!objectPath || !objectPath.startsWith("assets/")) return null;
165
+ if (objectPath.endsWith("/compressed")) return null;
166
+
167
+ const parts = objectPath.split("/");
168
+ const assetId = parts[parts.length - 1];
169
+ const apiKey = url.searchParams.get("apiKey") || parts[1];
170
+ if (!assetId || !apiKey) return null;
171
+
172
+ const compressedPath = `${objectPath}/compressed`;
173
+ const compressedUrl = new URL(
174
+ `/o/${encodeURIComponent(compressedPath)}`,
175
+ url.origin,
176
+ );
177
+ compressedUrl.searchParams.set("apiKey", apiKey);
178
+ compressedUrl.searchParams.set(
179
+ "token",
180
+ url.searchParams.get("token") || assetId,
181
+ );
182
+ compressedUrl.searchParams.set("alt", "media");
183
+ compressedUrl.searchParams.set("optimized", "true");
184
+ return compressedUrl.toString();
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function shouldSkipCompressedBuilderMediaProbe(
191
+ compressedSourceUrl: string,
192
+ ): boolean {
193
+ const expiresAt = compressedBuilderMediaMisses.get(compressedSourceUrl);
194
+ if (!expiresAt) return false;
195
+ if (expiresAt <= Date.now()) {
196
+ compressedBuilderMediaMisses.delete(compressedSourceUrl);
197
+ return false;
198
+ }
199
+ return true;
200
+ }
201
+
202
+ function rememberCompressedBuilderMediaMiss(compressedSourceUrl: string): void {
203
+ if (compressedBuilderMediaMisses.size > 1_000) {
204
+ for (const [url, expiresAt] of compressedBuilderMediaMisses) {
205
+ if (expiresAt <= Date.now()) compressedBuilderMediaMisses.delete(url);
206
+ }
207
+ }
208
+ compressedBuilderMediaMisses.set(
209
+ compressedSourceUrl,
210
+ Date.now() + COMPRESSED_BUILDER_MEDIA_MISS_TTL_MS,
211
+ );
212
+ }
213
+
214
+ function shouldFallbackToOriginalMedia(
215
+ upstream: Response | { error: string; status: number },
216
+ ): boolean {
217
+ return [403, 404, 416].includes(upstream.status);
218
+ }
219
+
220
+ function shouldRememberCompressedBuilderMediaMiss(
221
+ upstream: Response | { error: string; status: number },
222
+ ): boolean {
223
+ return [403, 404].includes(upstream.status);
224
+ }
225
+
148
226
  async function fetchProviderMedia(
149
227
  sourceUrl: string,
150
228
  rangeHeader: string | undefined,
@@ -434,7 +512,24 @@ export default defineEventHandler(async (event: H3Event) => {
434
512
 
435
513
  let upstream: Response | { error: string; status: number };
436
514
  try {
437
- upstream = await fetchProviderMedia(sourceUrl, rangeHeader);
515
+ const compressedSourceUrl = compressedBuilderMediaUrl(sourceUrl);
516
+ if (
517
+ compressedSourceUrl &&
518
+ !shouldSkipCompressedBuilderMediaProbe(compressedSourceUrl)
519
+ ) {
520
+ upstream = await fetchProviderMedia(
521
+ compressedSourceUrl,
522
+ rangeHeader,
523
+ );
524
+ if (shouldRememberCompressedBuilderMediaMiss(upstream)) {
525
+ rememberCompressedBuilderMediaMiss(compressedSourceUrl);
526
+ }
527
+ if (shouldFallbackToOriginalMedia(upstream)) {
528
+ upstream = await fetchProviderMedia(sourceUrl, rangeHeader);
529
+ }
530
+ } else {
531
+ upstream = await fetchProviderMedia(sourceUrl, rangeHeader);
532
+ }
438
533
  } catch (err) {
439
534
  setResponseStatus(event, statusCodeForProviderFetchError(err));
440
535
  return { error: messageForProviderFetchError(err) };