@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +21 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/caller-auth.ts +37 -8
- package/corpus/core/src/a2a/client.ts +178 -67
- package/corpus/core/src/agent/production-agent.ts +19 -3
- package/corpus/core/src/agent/run-store.ts +180 -13
- package/corpus/core/src/agent/types.ts +1 -1
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
- package/corpus/core/src/cli/recap.ts +43 -34
- package/corpus/core/src/client/AssistantChat.tsx +49 -0
- package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
- package/corpus/core/src/client/active-run-state.ts +30 -0
- package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +41 -3
- package/corpus/core/src/client/extensions/ExtensionViewer.tsx +133 -0
- package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +2 -2
- package/corpus/core/src/client/i18n.tsx +10 -5
- package/corpus/core/src/client/sse-event-processor.ts +147 -5
- package/corpus/core/src/extensions/html-shell.ts +17 -3
- package/corpus/core/src/extensions/theme.ts +39 -0
- package/corpus/core/src/file-upload/builder.ts +35 -14
- package/corpus/core/src/file-upload/types.ts +3 -0
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
- package/corpus/core/src/mcp/builtin-tools.ts +3 -0
- package/corpus/core/src/mcp/org-directory.ts +66 -20
- package/corpus/core/src/scripts/call-agent.ts +7 -2
- package/corpus/templates/analytics/actions/provider-api-request.ts +8 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +91 -33
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
- package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
- package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
- package/corpus/templates/clips/actions/create-recording.ts +7 -2
- package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
- package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
- package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
- package/corpus/templates/clips/actions/lib/loom-video.ts +16 -0
- package/corpus/templates/clips/app/components/player/scrubber.tsx +2 -2
- package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +65 -1
- package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
- package/corpus/templates/clips/app/routes/record.tsx +48 -8
- package/corpus/templates/clips/changelog/2026-07-01-chrome-recording-uploads-now-serialize-chunks-to-avoid-resum.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +94 -29
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
- package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +14 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
- package/corpus/templates/clips/shared/finalize-recovery.ts +242 -0
- package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
- package/corpus/templates/plan/app/global.css +1 -1
- package/dist/a2a/caller-auth.d.ts +1 -0
- package/dist/a2a/caller-auth.d.ts.map +1 -1
- package/dist/a2a/caller-auth.js +34 -8
- package/dist/a2a/caller-auth.js.map +1 -1
- package/dist/a2a/client.d.ts +4 -0
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +148 -58
- package/dist/a2a/client.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +19 -4
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-store.d.ts +19 -6
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +163 -13
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +1 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts +3 -3
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +41 -34
- package/dist/cli/recap.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +2 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +36 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/PoweredByBadge.d.ts +9 -3
- package/dist/client/PoweredByBadge.d.ts.map +1 -1
- package/dist/client/PoweredByBadge.js +47 -15
- package/dist/client/PoweredByBadge.js.map +1 -1
- package/dist/client/active-run-state.d.ts +3 -0
- package/dist/client/active-run-state.d.ts.map +1 -1
- package/dist/client/active-run-state.js +24 -0
- package/dist/client/active-run-state.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +5 -1
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +32 -3
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionViewer.js +59 -2
- package/dist/client/extensions/ExtensionViewer.js.map +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js +2 -2
- package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
- package/dist/client/i18n.d.ts +1 -1
- package/dist/client/i18n.d.ts.map +1 -1
- package/dist/client/i18n.js +6 -2
- package/dist/client/i18n.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +2 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +121 -5
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/extensions/html-shell.d.ts.map +1 -1
- package/dist/extensions/html-shell.js +17 -3
- package/dist/extensions/html-shell.js.map +1 -1
- package/dist/extensions/theme.d.ts +9 -0
- package/dist/extensions/theme.d.ts.map +1 -1
- package/dist/extensions/theme.js +38 -0
- package/dist/extensions/theme.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +22 -8
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/types.d.ts +5 -1
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/integrations/a2a-continuation-processor.js +43 -18
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +3 -0
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/mcp/org-directory.d.ts.map +1 -1
- package/dist/mcp/org-directory.js +58 -20
- package/dist/mcp/org-directory.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +6 -6
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +2 -2
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- 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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
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, {
|
|
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
|
-
|
|
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) };
|