@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +16 -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/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 +27 -4
- package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
- package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
- package/corpus/core/src/client/guided-questions.tsx +11 -6
- package/corpus/core/src/client/i18n.tsx +10 -5
- package/corpus/core/src/client/sse-event-processor.ts +147 -5
- package/corpus/core/src/client/tool-display.ts +8 -0
- package/corpus/core/src/file-upload/builder.ts +40 -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/notifications/channels.ts +235 -38
- package/corpus/core/src/scripts/call-agent.ts +7 -2
- package/corpus/core/src/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- package/corpus/templates/analytics/AGENTS.md +21 -0
- package/corpus/templates/analytics/README.md +35 -0
- package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
- package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
- package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
- package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
- package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
- package/corpus/templates/analytics/server/db/schema.ts +55 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +71 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
- package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
- package/corpus/templates/analytics/server/plugins/db.ts +100 -0
- package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
- package/corpus/templates/assets/README.md +30 -8
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/README.md +22 -28
- 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/chat/README.md +32 -0
- package/corpus/templates/clips/README.md +37 -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/library/library-grid.tsx +18 -7
- package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
- 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 +106 -2
- 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-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-move-to-folder-now-opens-folder-choices-directly-instead-of.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 +64 -6
- 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 +51 -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/content/AGENTS.md +7 -3
- package/corpus/templates/content/README.md +22 -34
- package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
- package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
- package/corpus/templates/content/app/i18n-data.ts +278 -40
- package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/AGENTS.md +7 -6
- package/corpus/templates/design/README.md +33 -0
- package/corpus/templates/design/actions/delete-file.ts +86 -10
- package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
- package/corpus/templates/design/actions/present-design-variants.ts +11 -13
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2 -2
- package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/plan/app/global.css +1 -1
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/README.md +22 -201
- 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/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.map +1 -1
- package/dist/client/AssistantChat.js +28 -4
- 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/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/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +10 -6
- package/dist/client/guided-questions.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/client/tool-display.d.ts.map +1 -1
- package/dist/client/tool-display.js +8 -0
- package/dist/client/tool-display.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/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +26 -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/channels.d.ts +10 -0
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +172 -20
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- 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/analytics.d.ts +16 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +30 -7
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +28 -12
- package/dist/server/ssr-handler.js.map +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
|
}
|
|
@@ -449,12 +467,50 @@ async function uploadChunk(
|
|
|
449
467
|
if (recording.authToken) {
|
|
450
468
|
headers.Authorization = `Bearer ${recording.authToken}`;
|
|
451
469
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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, {
|
|
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) };
|