@agent-native/core 0.84.20 → 0.84.22

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 (193) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -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/types.ts +1 -1
  8. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  9. package/corpus/core/src/cli/recap.ts +43 -34
  10. package/corpus/core/src/client/AssistantChat.tsx +27 -4
  11. package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
  12. package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
  13. package/corpus/core/src/client/guided-questions.tsx +11 -6
  14. package/corpus/core/src/client/i18n.tsx +10 -5
  15. package/corpus/core/src/client/sse-event-processor.ts +147 -5
  16. package/corpus/core/src/client/tool-display.ts +8 -0
  17. package/corpus/core/src/file-upload/builder.ts +40 -14
  18. package/corpus/core/src/file-upload/types.ts +3 -0
  19. package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
  20. package/corpus/core/src/mcp/builtin-tools.ts +3 -0
  21. package/corpus/core/src/mcp/org-directory.ts +66 -20
  22. package/corpus/core/src/notifications/channels.ts +235 -38
  23. package/corpus/core/src/scripts/call-agent.ts +7 -2
  24. package/corpus/core/src/server/analytics.ts +32 -6
  25. package/corpus/core/src/server/ssr-handler.ts +31 -12
  26. package/corpus/templates/analytics/AGENTS.md +21 -0
  27. package/corpus/templates/analytics/README.md +35 -0
  28. package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
  29. package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
  30. package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
  31. package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
  32. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
  33. package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
  35. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
  36. package/corpus/templates/analytics/netlify.toml +3 -0
  37. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
  38. package/corpus/templates/analytics/server/db/schema.ts +55 -0
  39. package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
  40. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +71 -0
  41. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -0
  42. package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
  43. package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
  44. package/corpus/templates/analytics/server/plugins/db.ts +100 -0
  45. package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
  46. package/corpus/templates/assets/README.md +30 -8
  47. package/corpus/templates/brain/README.md +20 -552
  48. package/corpus/templates/calendar/README.md +22 -28
  49. package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
  50. package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
  51. package/corpus/templates/chat/README.md +32 -0
  52. package/corpus/templates/clips/README.md +37 -0
  53. package/corpus/templates/clips/actions/create-recording.ts +7 -2
  54. package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
  55. package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
  56. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
  57. package/corpus/templates/clips/actions/lib/loom-video.ts +16 -0
  58. package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
  59. package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
  60. package/corpus/templates/clips/app/components/player/scrubber.tsx +2 -2
  61. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
  62. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
  63. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +106 -2
  64. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
  65. package/corpus/templates/clips/app/routes/record.tsx +48 -8
  66. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
  67. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
  68. package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
  69. package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
  70. package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
  71. package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
  72. package/corpus/templates/clips/changelog/2026-07-01-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
  73. package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
  74. package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
  75. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  76. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
  77. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +64 -6
  78. package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
  79. package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
  80. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +51 -1
  81. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
  82. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
  83. package/corpus/templates/clips/shared/finalize-recovery.ts +242 -0
  84. package/corpus/templates/content/AGENTS.md +7 -3
  85. package/corpus/templates/content/README.md +22 -34
  86. package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
  87. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
  88. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
  89. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
  90. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
  91. package/corpus/templates/content/app/i18n-data.ts +278 -40
  92. package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
  93. package/corpus/templates/content/shared/api.ts +1 -0
  94. package/corpus/templates/design/AGENTS.md +7 -6
  95. package/corpus/templates/design/README.md +33 -0
  96. package/corpus/templates/design/actions/delete-file.ts +86 -10
  97. package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
  98. package/corpus/templates/design/actions/present-design-variants.ts +11 -13
  99. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  100. package/corpus/templates/design/app/pages/DesignEditor.tsx +2 -2
  101. package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
  102. package/corpus/templates/dispatch/README.md +34 -0
  103. package/corpus/templates/forms/README.md +34 -0
  104. package/corpus/templates/macros/README.md +28 -0
  105. package/corpus/templates/mail/README.md +35 -0
  106. package/corpus/templates/plan/README.md +30 -118
  107. package/corpus/templates/plan/app/global.css +1 -1
  108. package/corpus/templates/slides/README.md +34 -0
  109. package/corpus/templates/videos/README.md +22 -201
  110. package/dist/a2a/caller-auth.d.ts +1 -0
  111. package/dist/a2a/caller-auth.d.ts.map +1 -1
  112. package/dist/a2a/caller-auth.js +34 -8
  113. package/dist/a2a/caller-auth.js.map +1 -1
  114. package/dist/a2a/client.d.ts +4 -0
  115. package/dist/a2a/client.d.ts.map +1 -1
  116. package/dist/a2a/client.js +148 -58
  117. package/dist/a2a/client.js.map +1 -1
  118. package/dist/agent/production-agent.d.ts.map +1 -1
  119. package/dist/agent/production-agent.js +19 -4
  120. package/dist/agent/production-agent.js.map +1 -1
  121. package/dist/agent/types.d.ts +1 -0
  122. package/dist/agent/types.d.ts.map +1 -1
  123. package/dist/agent/types.js.map +1 -1
  124. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  125. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  126. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  127. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  128. package/dist/cli/recap.d.ts +3 -3
  129. package/dist/cli/recap.d.ts.map +1 -1
  130. package/dist/cli/recap.js +41 -34
  131. package/dist/cli/recap.js.map +1 -1
  132. package/dist/client/AssistantChat.d.ts.map +1 -1
  133. package/dist/client/AssistantChat.js +28 -4
  134. package/dist/client/AssistantChat.js.map +1 -1
  135. package/dist/client/PoweredByBadge.d.ts +9 -3
  136. package/dist/client/PoweredByBadge.d.ts.map +1 -1
  137. package/dist/client/PoweredByBadge.js +47 -15
  138. package/dist/client/PoweredByBadge.js.map +1 -1
  139. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  140. package/dist/client/agent-chat-adapter.js +5 -1
  141. package/dist/client/agent-chat-adapter.js.map +1 -1
  142. package/dist/client/guided-questions.d.ts.map +1 -1
  143. package/dist/client/guided-questions.js +10 -6
  144. package/dist/client/guided-questions.js.map +1 -1
  145. package/dist/client/i18n.d.ts +1 -1
  146. package/dist/client/i18n.d.ts.map +1 -1
  147. package/dist/client/i18n.js +6 -2
  148. package/dist/client/i18n.js.map +1 -1
  149. package/dist/client/sse-event-processor.d.ts +2 -0
  150. package/dist/client/sse-event-processor.d.ts.map +1 -1
  151. package/dist/client/sse-event-processor.js +121 -5
  152. package/dist/client/sse-event-processor.js.map +1 -1
  153. package/dist/client/tool-display.d.ts.map +1 -1
  154. package/dist/client/tool-display.js +8 -0
  155. package/dist/client/tool-display.js.map +1 -1
  156. package/dist/collab/awareness.d.ts +2 -2
  157. package/dist/collab/awareness.d.ts.map +1 -1
  158. package/dist/collab/routes.d.ts +1 -1
  159. package/dist/file-upload/builder.d.ts.map +1 -1
  160. package/dist/file-upload/builder.js +26 -8
  161. package/dist/file-upload/builder.js.map +1 -1
  162. package/dist/file-upload/types.d.ts +5 -1
  163. package/dist/file-upload/types.d.ts.map +1 -1
  164. package/dist/file-upload/types.js.map +1 -1
  165. package/dist/integrations/a2a-continuation-processor.js +43 -18
  166. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  167. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  168. package/dist/mcp/builtin-tools.js +3 -0
  169. package/dist/mcp/builtin-tools.js.map +1 -1
  170. package/dist/mcp/org-directory.d.ts.map +1 -1
  171. package/dist/mcp/org-directory.js +58 -20
  172. package/dist/mcp/org-directory.js.map +1 -1
  173. package/dist/notifications/channels.d.ts +10 -0
  174. package/dist/notifications/channels.d.ts.map +1 -1
  175. package/dist/notifications/channels.js +172 -20
  176. package/dist/notifications/channels.js.map +1 -1
  177. package/dist/notifications/routes.d.ts +3 -3
  178. package/dist/observability/routes.d.ts +5 -5
  179. package/dist/progress/routes.d.ts +1 -1
  180. package/dist/resources/handlers.d.ts +1 -1
  181. package/dist/scripts/call-agent.d.ts.map +1 -1
  182. package/dist/scripts/call-agent.js +2 -2
  183. package/dist/scripts/call-agent.js.map +1 -1
  184. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  185. package/dist/server/analytics.d.ts +16 -0
  186. package/dist/server/analytics.d.ts.map +1 -1
  187. package/dist/server/analytics.js +30 -7
  188. package/dist/server/analytics.js.map +1 -1
  189. package/dist/server/ssr-handler.d.ts.map +1 -1
  190. package/dist/server/ssr-handler.js +28 -12
  191. package/dist/server/ssr-handler.js.map +1 -1
  192. package/dist/server/transcribe-voice.d.ts +1 -1
  193. 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
  }
@@ -449,12 +467,50 @@ async function uploadChunk(
449
467
  if (recording.authToken) {
450
468
  headers.Authorization = `Bearer ${recording.authToken}`;
451
469
  }
452
- const res = await fetch(url, {
453
- method: "POST",
454
- headers,
455
- credentials: "include",
456
- body,
457
- });
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
+
458
514
  const text = await res.text().catch(() => "");
459
515
  let data: UploadResult = {};
460
516
  if (text) {
@@ -482,9 +538,11 @@ async function uploadChunk(
482
538
  `Upload failed (${res.status}): ${text || res.statusText}`,
483
539
  );
484
540
  const uploadError = error as {
541
+ finalUploadRecoveryAttempted?: boolean;
485
542
  status?: number;
486
543
  storageSetupRequired?: boolean;
487
544
  };
545
+ uploadError.finalUploadRecoveryAttempted = triedFinalUploadRecovery;
488
546
  uploadError.status = res.status;
489
547
  uploadError.storageSetupRequired = storageSetupRequired;
490
548
  captureExtensionError(error, {
@@ -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
+ }
@@ -19,6 +19,7 @@ import {
19
19
  } from "@agent-native/core/application-state";
20
20
  import { getActiveFileUploadProvider } from "@agent-native/core/file-upload";
21
21
  import { runWithRequestContext } from "@agent-native/core/server";
22
+ import { track } from "@agent-native/core/tracking";
22
23
  import { normalizeChunkUploadNumber } from "@shared/recording-core.js";
23
24
  import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
24
25
  import { and, eq } from "drizzle-orm";
@@ -46,6 +47,7 @@ import {
46
47
  setResumableSession,
47
48
  type StoredResumableSession,
48
49
  } from "../../../../lib/resumable-session.js";
50
+ import { isStreamingUploadDisabled } from "../../../../lib/streaming-upload-mode.js";
49
51
  import {
50
52
  shouldRejectVideoUploadWithoutStorage,
51
53
  STORAGE_SETUP_REQUIRED_REASON,
@@ -94,6 +96,26 @@ function stateNumber(
94
96
  return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
95
97
  }
96
98
 
99
+ function trackUploadBlockingFailure(
100
+ ownerEmail: string,
101
+ properties: Record<string, unknown>,
102
+ ): void {
103
+ try {
104
+ track(
105
+ "clips_upload_blocking_failure",
106
+ {
107
+ app: "clips",
108
+ template: "clips",
109
+ surface: "server_upload",
110
+ ...properties,
111
+ },
112
+ { userId: ownerEmail },
113
+ );
114
+ } catch {
115
+ // Best-effort analytics must never change upload behavior.
116
+ }
117
+ }
118
+
97
119
  export default defineEventHandler(async (event: H3Event) => {
98
120
  const recordingId = getRouterParam(event, "recordingId");
99
121
  if (!recordingId) {
@@ -174,6 +196,11 @@ export default defineEventHandler(async (event: H3Event) => {
174
196
 
175
197
  // Resumable streaming path — forward chunks directly to the provider.
176
198
  const resumableSession = await getResumableSession(recordingId);
199
+ if (resumableSession && isStreamingUploadDisabled()) {
200
+ console.warn(
201
+ `[chunk] streaming uploads are disabled, but preserving existing resumable session for in-flight recording: ${recordingId}`,
202
+ );
203
+ }
177
204
  if (resumableSession) {
178
205
  return handleResumableChunk(
179
206
  event,
@@ -183,6 +210,7 @@ export default defineEventHandler(async (event: H3Event) => {
183
210
  isFinal,
184
211
  mimeType,
185
212
  query,
213
+ ownerEmail,
186
214
  );
187
215
  }
188
216
 
@@ -501,6 +529,13 @@ export default defineEventHandler(async (event: H3Event) => {
501
529
  hasCamera: committed.hasCamera,
502
530
  };
503
531
  }
532
+ trackUploadBlockingFailure(ownerEmail, {
533
+ stage: "finalize_recording",
534
+ failureKind: "finalize_error",
535
+ recordingId,
536
+ uploadMode: "buffered",
537
+ errorMessage: err instanceof Error ? err.message : String(err),
538
+ });
504
539
  await db
505
540
  .update(schema.recordings)
506
541
  .set({
@@ -560,6 +595,7 @@ async function handleResumableChunk(
560
595
  isFinal: boolean,
561
596
  mimeType: string,
562
597
  query: Record<string, unknown>,
598
+ ownerEmail: string,
563
599
  ) {
564
600
  const uploadProvider = getActiveFileUploadProvider();
565
601
  console.log(
@@ -574,6 +610,13 @@ async function handleResumableChunk(
574
610
  }
575
611
 
576
612
  if (isFinal && bytes.byteLength === 0) {
613
+ if (session.bytesUploaded <= 0) {
614
+ setResponseStatus(event, 400);
615
+ return {
616
+ ok: false,
617
+ error: "Cannot finalize an empty resumable upload",
618
+ };
619
+ }
577
620
  // 0-byte sentinel from the recorder after stop(). All data chunks have
578
621
  // already been PUT to the provider; send Content-Range: bytes */<total>
579
622
  // to close the session before handing off to finalize-recording.
@@ -582,7 +625,7 @@ async function handleResumableChunk(
582
625
  `bytes */${session.bytesUploaded}`,
583
626
  new Uint8Array(0),
584
627
  );
585
- if (!closeRes.ok) {
628
+ if (!closeRes.ok || closeRes.status === 308) {
586
629
  console.error(
587
630
  `[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
588
631
  );
@@ -668,6 +711,13 @@ async function handleResumableChunk(
668
711
  return { ok: true, finalized: true, ...result };
669
712
  } catch (err) {
670
713
  console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
714
+ trackUploadBlockingFailure(ownerEmail, {
715
+ stage: "finalize_recording",
716
+ failureKind: "finalize_error",
717
+ recordingId,
718
+ uploadMode: "resumable",
719
+ errorMessage: err instanceof Error ? err.message : String(err),
720
+ });
671
721
  setResponseStatus(event, 500);
672
722
  return {
673
723
  ok: false,
@@ -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) };