@agent-native/core 0.157.0 → 0.157.2

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 (114) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +19 -4
  3. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +7 -2
  4. package/corpus/templates/assets/app/global.css +4 -0
  5. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +1 -2
  6. package/corpus/templates/brain/app/routes/sources.tsx +1 -2
  7. package/corpus/templates/calendar/app/hooks/use-bookings.ts +12 -2
  8. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +64 -2
  9. package/corpus/templates/calendar/server/handlers/bookings.ts +224 -93
  10. package/corpus/templates/calendar/server/plugins/auth.ts +0 -2
  11. package/corpus/templates/clips/actions/create-folder.ts +7 -1
  12. package/corpus/templates/clips/actions/create-meeting.ts +6 -2
  13. package/corpus/templates/clips/actions/create-recording.ts +5 -3
  14. package/corpus/templates/clips/actions/finalize-recording.ts +97 -65
  15. package/corpus/templates/clips/actions/import-loom-recording.ts +5 -3
  16. package/corpus/templates/clips/actions/request-recording-access.ts +305 -0
  17. package/corpus/templates/clips/actions/stitch-recordings.ts +5 -3
  18. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +38 -63
  19. package/corpus/templates/clips/app/components/player/share-dialog.tsx +75 -75
  20. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +3 -21
  21. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +13 -30
  22. package/corpus/templates/clips/app/i18n/en-US.ts +10 -0
  23. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +5 -5
  24. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +9 -16
  25. package/corpus/templates/clips/app/routes/share.$shareId.tsx +135 -18
  26. package/corpus/templates/clips/changelog/2026-08-14-long-resumable-uploads-now-abort-cleanly-and-show-errors.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-08-14-standardized-share-popovers.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/public/manifest.json +11 -3
  29. package/corpus/templates/clips/chrome-extension/src/github-preview-content.ts +37 -8
  30. package/corpus/templates/clips/chrome-extension/src/github-preview.ts +3 -3
  31. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +8 -25
  32. package/corpus/templates/clips/desktop/src/app.tsx +24 -0
  33. package/corpus/templates/clips/desktop/src/lib/media-capture-constraints.ts +42 -13
  34. package/corpus/templates/clips/desktop/src/lib/recorder.ts +11 -18
  35. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +1 -0
  36. package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
  37. package/corpus/templates/clips/desktop/src/styles.css +24 -9
  38. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +3 -0
  39. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +8 -0
  40. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +154 -176
  41. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +115 -31
  42. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +7 -0
  43. package/corpus/templates/clips/server/db/index.ts +1 -0
  44. package/corpus/templates/clips/server/db/schema.ts +1 -0
  45. package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +6 -1
  46. package/corpus/templates/clips/server/lib/emails.ts +23 -0
  47. package/corpus/templates/clips/server/lib/public-agent-context.ts +55 -0
  48. package/corpus/templates/clips/server/lib/recordings.ts +2 -1
  49. package/corpus/templates/clips/server/lib/resumable-upload-cleanup.ts +49 -0
  50. package/corpus/templates/clips/server/lib/s3-upload-provider.ts +34 -7
  51. package/corpus/templates/clips/server/lib/upload-interruption.ts +15 -1
  52. package/corpus/templates/clips/server/lib/upload-lease.ts +94 -9
  53. package/corpus/templates/clips/server/routes/api/agent-context.json.get.ts +82 -7
  54. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +1 -0
  55. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +71 -12
  56. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +4 -3
  57. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +133 -4
  58. package/corpus/templates/clips/server/routes/api/uploads/reap.post.ts +8 -1
  59. package/corpus/templates/clips/shared/recording-link.ts +4 -0
  60. package/corpus/templates/clips/shared/share-loader-response.ts +2 -1
  61. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +7 -16
  62. package/corpus/templates/factory/AGENTS.md +5 -1
  63. package/corpus/templates/factory/README.md +6 -2
  64. package/corpus/templates/factory/actions/view-screen.ts +15 -0
  65. package/corpus/templates/factory/app/components/factory/FactoryAgentsView.tsx +58 -189
  66. package/corpus/templates/factory/app/components/layout/Header.tsx +1 -0
  67. package/corpus/templates/factory/app/components/layout/Layout.tsx +1 -1
  68. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +7 -0
  69. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +3 -0
  70. package/corpus/templates/factory/app/i18n/en-US.ts +3 -1
  71. package/corpus/templates/factory/app/routes/agents.tsx +9 -0
  72. package/corpus/templates/mail/server/plugins/auth.ts +0 -2
  73. package/corpus/templates/plan/app/components/plan/LocalCodebasePicker.tsx +50 -14
  74. package/corpus/templates/plan/app/pages/PlansPage.tsx +9 -19
  75. package/corpus/templates/slides/app/components/editor/HistoryPanel.tsx +7 -1
  76. package/corpus/templates/slides/app/context/DeckContext.tsx +71 -22
  77. package/corpus/templates/slides/app/lib/editor-shortcuts.ts +46 -0
  78. package/corpus/templates/slides/app/pages/DeckEditor.tsx +13 -0
  79. package/dist/client/AgentPanel.d.ts +13 -1
  80. package/dist/client/AgentPanel.js +12 -10
  81. package/dist/client/AssistantChat.js +25 -5
  82. package/dist/client/HighlightedCodeBlock.js +12 -2
  83. package/dist/client/agent-chat/index.d.ts +1 -0
  84. package/dist/client/agent-chat/index.js +1 -0
  85. package/dist/client/chat/markdown-renderer.js +16 -3
  86. package/dist/client/chat-first/apps-rail.js +1 -1
  87. package/dist/client/components/ui/message-scroller.js +1 -1
  88. package/dist/client/settings/AccountSettingsCard.js +48 -19
  89. package/dist/client/sharing/ShareButton.d.ts +1 -2
  90. package/dist/client/sharing/ShareButton.js +5 -41
  91. package/dist/client/sharing/ShareDialog.js +5 -4
  92. package/dist/client/use-chat-threads.js +10 -14
  93. package/dist/collab/awareness.d.ts +2 -2
  94. package/dist/collab/routes.d.ts +1 -1
  95. package/dist/collab/struct-routes.d.ts +1 -1
  96. package/dist/localization/default-messages.d.ts +1 -0
  97. package/dist/localization/default-messages.js +1 -0
  98. package/dist/notifications/routes.d.ts +3 -3
  99. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  100. package/dist/provider-api/actions/provider-api.d.ts +15 -15
  101. package/dist/server/auth-marketing.d.ts +1 -0
  102. package/dist/server/auth-marketing.js +0 -2
  103. package/dist/server/auth.d.ts +1 -0
  104. package/dist/server/auth.js +18 -0
  105. package/dist/server/onboarding-html.d.ts +1 -0
  106. package/dist/server/onboarding-html.js +2 -88
  107. package/dist/server/realtime-token.d.ts +1 -1
  108. package/dist/shared/markdown-block-split.d.ts +16 -0
  109. package/dist/shared/markdown-block-split.js +81 -5
  110. package/dist/styles/agent-native.css +13 -0
  111. package/dist/voice/voice-cleanup-prompt.js +1 -0
  112. package/docs/content/cloneable-saas.mdx +24 -26
  113. package/package.json +3 -3
  114. package/corpus/templates/clips/shared/mic-audio-cleanup.ts +0 -350
@@ -784,13 +784,40 @@ export const s3FileUploadProvider: FileUploadProvider = {
784
784
  )
785
785
  .join("") +
786
786
  "</CompleteMultipartUpload>";
787
- const res = await signedS3Request(cfg, meta.objectKey, {
788
- method: "POST",
789
- query: { uploadId: session.sessionId },
790
- body: new TextEncoder().encode(manifest),
791
- contentType: "application/xml",
792
- timeoutMs: S3_PUT_TIMEOUT_MS,
793
- });
787
+ let res: Response;
788
+ try {
789
+ res = await signedS3Request(cfg, meta.objectKey, {
790
+ method: "POST",
791
+ query: { uploadId: session.sessionId },
792
+ body: new TextEncoder().encode(manifest),
793
+ contentType: "application/xml",
794
+ timeoutMs: S3_PUT_TIMEOUT_MS,
795
+ });
796
+ } catch (error) {
797
+ // R2 can finish the object while the HTTP response is lost. Reconcile
798
+ // that ambiguous case before the caller aborts the provider session.
799
+ try {
800
+ if (await verifyCompletedMultipartObject(cfg, meta)) {
801
+ await deleteObject(cfg, meta.stagingKey).catch((cleanupError) => {
802
+ console.warn(
803
+ "[s3-upload] failed to delete multipart staging object:",
804
+ cleanupError instanceof Error
805
+ ? cleanupError.message
806
+ : String(cleanupError),
807
+ );
808
+ });
809
+ return publicObjectUrl(cfg, meta.objectKey);
810
+ }
811
+ } catch (verificationError) {
812
+ console.warn(
813
+ "[s3-upload] completion reconciliation failed:",
814
+ verificationError,
815
+ );
816
+ }
817
+ throw new Error(
818
+ `S3 CompleteMultipartUpload failed: ${error instanceof Error ? error.message : String(error)}`,
819
+ );
820
+ }
794
821
  const body = await res.text().catch(() => "");
795
822
  if (!res.ok || /<Error(?:\s|>)/.test(body)) {
796
823
  // CompleteMultipartUpload is not idempotent at the S3 API level. If
@@ -1,8 +1,22 @@
1
1
  export const RETRYABLE_UPLOAD_INTERRUPTION_REASON =
2
2
  "Upload was interrupted. The local recording is safe; retry from the Clips desktop app.";
3
3
 
4
+ export function retryableUploadInterruptionReason(
5
+ detail: string | null | undefined,
6
+ ): string {
7
+ const normalized = detail?.trim();
8
+ return normalized
9
+ ? `${RETRYABLE_UPLOAD_INTERRUPTION_REASON} Last error: ${normalized}`
10
+ : RETRYABLE_UPLOAD_INTERRUPTION_REASON;
11
+ }
12
+
4
13
  export function isRetryableUploadInterruption(
5
14
  failureReason: string | null | undefined,
6
15
  ): boolean {
7
- return failureReason === RETRYABLE_UPLOAD_INTERRUPTION_REASON;
16
+ return (
17
+ failureReason === RETRYABLE_UPLOAD_INTERRUPTION_REASON ||
18
+ failureReason?.startsWith(
19
+ `${RETRYABLE_UPLOAD_INTERRUPTION_REASON} Last error:`,
20
+ ) === true
21
+ );
8
22
  }
@@ -15,6 +15,8 @@ import { getDbExec, isPostgres } from "@agent-native/core/db";
15
15
  import { and, eq, inArray, isNull, sql } from "drizzle-orm";
16
16
 
17
17
  import { getDb, schema } from "../db/index.js";
18
+ import type { StoredResumableSession } from "./resumable-session.js";
19
+ import { abortResumableUploadSession } from "./resumable-upload-cleanup.js";
18
20
 
19
21
  /**
20
22
  * ponytail: one horizon for both in-progress statuses. A paused recorder emits
@@ -148,6 +150,61 @@ export interface ReapResult {
148
150
  expired: ReapedUpload[];
149
151
  failed: number;
150
152
  scratchKeysDeleted: number;
153
+ resumableSessionsAborted: number;
154
+ resumableCleanupFailed: number;
155
+ }
156
+
157
+ function resumableSessionKey(
158
+ recordingId: string,
159
+ generationId: string | null,
160
+ ): string {
161
+ return generationId
162
+ ? `resumable-session-${recordingId}-${generationId}`
163
+ : `resumable-session-${recordingId}`;
164
+ }
165
+
166
+ function parseStoredResumableSession(
167
+ value: unknown,
168
+ ): StoredResumableSession | null {
169
+ let parsed: unknown = value;
170
+ if (typeof parsed === "string") {
171
+ try {
172
+ parsed = JSON.parse(parsed);
173
+ } catch {
174
+ // coercion-ok: malformed persisted session is absent, not an active session.
175
+ return null;
176
+ }
177
+ }
178
+ if (!parsed || typeof parsed !== "object") return null;
179
+ const candidate = parsed as Record<string, unknown>;
180
+ if (
181
+ typeof candidate.providerId !== "string" ||
182
+ typeof candidate.sessionId !== "string" ||
183
+ !candidate.meta ||
184
+ typeof candidate.meta !== "object" ||
185
+ typeof candidate.bytesUploaded !== "number" ||
186
+ !Number.isFinite(candidate.bytesUploaded) ||
187
+ candidate.bytesUploaded < 0
188
+ ) {
189
+ return null;
190
+ }
191
+ return candidate as unknown as StoredResumableSession;
192
+ }
193
+
194
+ async function readResumableSessionState(
195
+ exec: ReturnType<typeof getDbExec>,
196
+ placeholder: string,
197
+ key: string,
198
+ ): Promise<{ present: boolean; session: StoredResumableSession | null }> {
199
+ const result = await exec.execute({
200
+ sql: `SELECT value FROM application_state WHERE key = ${placeholder}`,
201
+ args: [key],
202
+ });
203
+ const row = (result.rows as Array<{ value?: unknown }> | undefined)?.[0];
204
+ return {
205
+ present: row !== undefined,
206
+ session: row === undefined ? null : parseStoredResumableSession(row.value),
207
+ };
151
208
  }
152
209
 
153
210
  /**
@@ -196,6 +253,8 @@ export async function reapExpiredUploads(
196
253
  }));
197
254
 
198
255
  let failed = 0;
256
+ let resumableSessionsAborted = 0;
257
+ let resumableCleanupFailed = 0;
199
258
  if (expired.length > 0 && !dryRun) {
200
259
  const ids = expired.map((row) => row.id);
201
260
  const result = await exec.execute({
@@ -223,16 +282,35 @@ export async function reapExpiredUploads(
223
282
  failed = terminated.size;
224
283
 
225
284
  for (const id of terminated) {
226
- const generationId = expired.find(
227
- (row) => row.id === id,
228
- )?.uploadGenerationId;
285
+ const generationId =
286
+ expired.find((row) => row.id === id)?.uploadGenerationId ?? null;
287
+ const sessionKey = resumableSessionKey(id, generationId);
288
+ const sessionState = await readResumableSessionState(
289
+ exec,
290
+ p(1),
291
+ sessionKey,
292
+ );
293
+ if (sessionState.present && !sessionState.session) {
294
+ resumableCleanupFailed += 1;
295
+ console.warn(
296
+ `[upload-reaper-${id}] resumable session state is unreadable; keeping it for manual cleanup`,
297
+ );
298
+ continue;
299
+ }
300
+ if (sessionState.session) {
301
+ const cleaned = await abortResumableUploadSession(
302
+ sessionState.session,
303
+ { label: `upload-reaper-${id}` },
304
+ );
305
+ if (!cleaned) {
306
+ resumableCleanupFailed += 1;
307
+ continue;
308
+ }
309
+ resumableSessionsAborted += 1;
310
+ }
229
311
  await exec.execute({
230
312
  sql: `DELETE FROM application_state WHERE key = ${p(1)}`,
231
- args: [
232
- generationId
233
- ? `resumable-session-${id}-${generationId}`
234
- : `resumable-session-${id}`,
235
- ],
313
+ args: [sessionKey],
236
314
  });
237
315
  }
238
316
  }
@@ -241,7 +319,14 @@ export async function reapExpiredUploads(
241
319
  ? (await selectUnclaimedChunkKeys(limit)).length
242
320
  : await deleteUnclaimedChunkScratch(limit);
243
321
 
244
- return { dryRun, expired, failed, scratchKeysDeleted };
322
+ return {
323
+ dryRun,
324
+ expired,
325
+ failed,
326
+ scratchKeysDeleted,
327
+ resumableSessionsAborted,
328
+ resumableCleanupFailed,
329
+ };
245
330
  }
246
331
 
247
332
  /**
@@ -6,6 +6,7 @@
6
6
  * smaller agent-oriented shape plus discoverable transcript/frame APIs.
7
7
  */
8
8
 
9
+ import { asc, count, eq } from "drizzle-orm";
9
10
  import {
10
11
  defineEventHandler,
11
12
  getQuery,
@@ -13,6 +14,7 @@ import {
13
14
  type H3Event,
14
15
  } from "h3";
15
16
 
17
+ import { getDb, schema } from "../../db/index.js";
16
18
  import {
17
19
  applyAgentJsonHeaders,
18
20
  buildPublicAgentContext,
@@ -21,6 +23,7 @@ import {
21
23
  loadAgentCtas,
22
24
  loadAgentTranscript,
23
25
  loadPublicAgentAccess,
26
+ MAX_PUBLIC_AGENT_HISTORY_ITEMS,
24
27
  parseAgentChapters,
25
28
  queryString,
26
29
  CLIPS_AGENT_ACCESS_PARAM,
@@ -42,14 +45,76 @@ export default defineEventHandler(async (event: H3Event) => {
42
45
  }
43
46
 
44
47
  const recording = accessResult.access.recording;
45
- const [{ transcript, agentSegments }, ctas, browserDiagnostics, bugReport] =
46
- await Promise.all([
47
- loadAgentTranscript(recording.id, recording.durationMs),
48
- loadAgentCtas(recording.id),
49
- loadAgentBrowserDiagnostics(recording.id),
50
- loadAgentBugReport(recording.id),
51
- ]);
48
+ const db = getDb();
49
+ const historyQueryLimit = MAX_PUBLIC_AGENT_HISTORY_ITEMS + 1;
50
+ const [
51
+ { transcript, agentSegments },
52
+ commentRows,
53
+ reactionRows,
54
+ commentCountRows,
55
+ reactionCountRows,
56
+ ctas,
57
+ browserDiagnostics,
58
+ bugReport,
59
+ ] = await Promise.all([
60
+ loadAgentTranscript(recording.id, recording.durationMs),
61
+ recording.enableComments
62
+ ? db
63
+ .select({
64
+ id: schema.recordingComments.id,
65
+ recordingId: schema.recordingComments.recordingId,
66
+ threadId: schema.recordingComments.threadId,
67
+ parentId: schema.recordingComments.parentId,
68
+ authorName: schema.recordingComments.authorName,
69
+ content: schema.recordingComments.content,
70
+ videoTimestampMs: schema.recordingComments.videoTimestampMs,
71
+ resolved: schema.recordingComments.resolved,
72
+ createdAt: schema.recordingComments.createdAt,
73
+ updatedAt: schema.recordingComments.updatedAt,
74
+ })
75
+ .from(schema.recordingComments)
76
+ .where(eq(schema.recordingComments.recordingId, recording.id))
77
+ .orderBy(
78
+ asc(schema.recordingComments.videoTimestampMs),
79
+ asc(schema.recordingComments.createdAt),
80
+ )
81
+ .limit(historyQueryLimit)
82
+ : Promise.resolve([]),
83
+ recording.enableReactions
84
+ ? db
85
+ .select({
86
+ id: schema.recordingReactions.id,
87
+ emoji: schema.recordingReactions.emoji,
88
+ videoTimestampMs: schema.recordingReactions.videoTimestampMs,
89
+ viewerName: schema.recordingReactions.viewerName,
90
+ createdAt: schema.recordingReactions.createdAt,
91
+ })
92
+ .from(schema.recordingReactions)
93
+ .where(eq(schema.recordingReactions.recordingId, recording.id))
94
+ .orderBy(asc(schema.recordingReactions.createdAt))
95
+ .limit(historyQueryLimit)
96
+ : Promise.resolve([]),
97
+ recording.enableComments
98
+ ? db
99
+ .select({ count: count() })
100
+ .from(schema.recordingComments)
101
+ .where(eq(schema.recordingComments.recordingId, recording.id))
102
+ : Promise.resolve([{ count: 0 }]),
103
+ recording.enableReactions
104
+ ? db
105
+ .select({ count: count() })
106
+ .from(schema.recordingReactions)
107
+ .where(eq(schema.recordingReactions.recordingId, recording.id))
108
+ : Promise.resolve([{ count: 0 }]),
109
+ loadAgentCtas(recording.id),
110
+ loadAgentBrowserDiagnostics(recording.id),
111
+ loadAgentBugReport(recording.id),
112
+ ]);
52
113
  const chapters = parseAgentChapters(recording);
114
+ const comments = commentRows.slice(0, MAX_PUBLIC_AGENT_HISTORY_ITEMS);
115
+ const reactions = reactionRows.slice(0, MAX_PUBLIC_AGENT_HISTORY_ITEMS);
116
+ const commentCount = Number(commentCountRows[0]?.count ?? comments.length);
117
+ const reactionCount = Number(reactionCountRows[0]?.count ?? reactions.length);
53
118
 
54
119
  return buildPublicAgentContext({
55
120
  event,
@@ -57,6 +122,16 @@ export default defineEventHandler(async (event: H3Event) => {
57
122
  transcript,
58
123
  agentSegments,
59
124
  chapters,
125
+ comments,
126
+ reactions,
127
+ commentCount,
128
+ commentsTruncated:
129
+ commentRows.length > MAX_PUBLIC_AGENT_HISTORY_ITEMS ||
130
+ commentCount > comments.length,
131
+ reactionCount,
132
+ reactionsTruncated:
133
+ reactionRows.length > MAX_PUBLIC_AGENT_HISTORY_ITEMS ||
134
+ reactionCount > reactions.length,
60
135
  ctas,
61
136
  browserDiagnostics,
62
137
  bugReport,
@@ -263,6 +263,7 @@ export default defineEventHandler(async (event) => {
263
263
  !viewerIsOrgMember &&
264
264
  !tokenAllowsAgentAccess
265
265
  ) {
266
+ setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
266
267
  setResponseStatus(event, 404);
267
268
  return { error: "Not found" };
268
269
  }
@@ -52,6 +52,7 @@ import {
52
52
  setResumableSession,
53
53
  type StoredResumableSession,
54
54
  } from "../../../../lib/resumable-session.js";
55
+ import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
55
56
  import { resolveResumableUploadProvider } from "../../../../lib/resumable-upload-provider.js";
56
57
  import { isStreamingUploadDisabled } from "../../../../lib/streaming-upload-mode.js";
57
58
  import { renewUploadLease } from "../../../../lib/upload-lease.js";
@@ -854,6 +855,23 @@ async function handleResumableChunk(
854
855
  `[resumable-chunk-${recordingId}] resumable session exists - bytesUploaded=${session.bytesUploaded} index=${index} isFinal=${isFinal}`,
855
856
  );
856
857
 
858
+ const cleanupFailedFinalSession = async () => {
859
+ const cleaned = await abortResumableUploadSession(session, {
860
+ provider: uploadProvider,
861
+ label: `resumable-final-${recordingId}`,
862
+ });
863
+ if (cleaned) {
864
+ await deleteResumableSession(recordingId, uploadGenerationId).catch(
865
+ (error) =>
866
+ console.warn(
867
+ `[resumable-chunk-${recordingId}] failed to retire aborted session:`,
868
+ error,
869
+ ),
870
+ );
871
+ }
872
+ return !cleaned;
873
+ };
874
+
857
875
  const raw = await readRawBody(event, false);
858
876
  const bytes: Uint8Array = raw ?? new Uint8Array(0);
859
877
  let finalizedSourceSizeBytes = session.bytesUploaded;
@@ -884,19 +902,37 @@ async function handleResumableChunk(
884
902
  // 0-byte sentinel from the recorder after stop(). All data chunks have
885
903
  // already been PUT to the provider; send Content-Range: bytes */<total>
886
904
  // to close the session before handing off to finalize-recording.
887
- const closeRes = await uploadProvider.resumable.relayChunk(
888
- { sessionId: session.sessionId, meta: session.meta },
889
- `bytes */${session.bytesUploaded}`,
890
- new Uint8Array(0),
891
- );
905
+ let closeRes;
906
+ try {
907
+ closeRes = await uploadProvider.resumable.relayChunk(
908
+ { sessionId: session.sessionId, meta: session.meta },
909
+ `bytes */${session.bytesUploaded}`,
910
+ new Uint8Array(0),
911
+ );
912
+ } catch (error) {
913
+ const cleanupFailed = await cleanupFailedFinalSession();
914
+ const detail = error instanceof Error ? error.message : String(error);
915
+ console.error(
916
+ `[resumable-chunk-${recordingId}] session close threw:`,
917
+ error,
918
+ );
919
+ setResponseStatus(event, 502);
920
+ return {
921
+ ok: false,
922
+ error: `Resumable session close failed: ${detail}`,
923
+ ...(cleanupFailed ? { cleanupFailed: true } : {}),
924
+ };
925
+ }
892
926
  if (!closeRes.ok || closeRes.status === 308) {
893
927
  console.error(
894
928
  `[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
895
929
  );
930
+ const cleanupFailed = await cleanupFailedFinalSession();
896
931
  setResponseStatus(event, 502);
897
932
  return {
898
933
  ok: false,
899
934
  error: `Resumable session close failed (${closeRes.status})`,
935
+ ...(cleanupFailed ? { cleanupFailed: true } : {}),
900
936
  };
901
937
  }
902
938
  if (closeRes.updatedMeta) {
@@ -964,12 +1000,31 @@ async function handleResumableChunk(
964
1000
  : `bytes ${start}-${end}/*`;
965
1001
 
966
1002
  const putT0 = Date.now();
967
- const putResult = await uploadProvider.resumable.relayChunk(
968
- { sessionId: session.sessionId, meta: session.meta },
969
- contentRange,
970
- bytes,
971
- { mimeType: mimeType.split(";")[0].trim() },
972
- );
1003
+ let putResult;
1004
+ try {
1005
+ putResult = await uploadProvider.resumable.relayChunk(
1006
+ { sessionId: session.sessionId, meta: session.meta },
1007
+ contentRange,
1008
+ bytes,
1009
+ { mimeType: mimeType.split(";")[0].trim() },
1010
+ );
1011
+ } catch (error) {
1012
+ if (isFinal) {
1013
+ const cleanupFailed = await cleanupFailedFinalSession();
1014
+ const detail = error instanceof Error ? error.message : String(error);
1015
+ console.error(
1016
+ `[resumable-chunk-${recordingId}] final chunk upload threw:`,
1017
+ error,
1018
+ );
1019
+ setResponseStatus(event, 502);
1020
+ return {
1021
+ ok: false,
1022
+ error: `Final chunk upload failed: ${detail}`,
1023
+ ...(cleanupFailed ? { cleanupFailed: true } : {}),
1024
+ };
1025
+ }
1026
+ throw error;
1027
+ }
973
1028
  console.log(
974
1029
  `[resumable-chunk-${recordingId}] PUT ${Date.now() - putT0}ms status=${putResult.status} range="${contentRange}"`,
975
1030
  );
@@ -980,7 +1035,10 @@ async function handleResumableChunk(
980
1035
  if (!resultOk) {
981
1036
  const restartRequired =
982
1037
  putResult.status === 404 || putResult.status === 410;
983
- if (restartRequired) {
1038
+ const cleanupFailed = isFinal
1039
+ ? await cleanupFailedFinalSession()
1040
+ : false;
1041
+ if (restartRequired && !cleanupFailed) {
984
1042
  await deleteResumableSession(recordingId, uploadGenerationId).catch(
985
1043
  () => {},
986
1044
  );
@@ -990,6 +1048,7 @@ async function handleResumableChunk(
990
1048
  ok: false,
991
1049
  error: `Chunk upload failed (${putResult.status})`,
992
1050
  ...(restartRequired ? { restartRequired: true } : {}),
1051
+ ...(cleanupFailed ? { cleanupFailed: true } : {}),
993
1052
  };
994
1053
  }
995
1054
 
@@ -31,7 +31,7 @@ import {
31
31
  } from "../../../../lib/recordings.js";
32
32
  import {
33
33
  isRetryableUploadInterruption,
34
- RETRYABLE_UPLOAD_INTERRUPTION_REASON,
34
+ retryableUploadInterruptionReason,
35
35
  } from "../../../../lib/upload-interruption.js";
36
36
  import abortUpload from "./abort.post.js";
37
37
 
@@ -131,12 +131,13 @@ export default defineEventHandler(async (event: H3Event) => {
131
131
  const uploadStateRaw = await readAppState(uploadStateKey);
132
132
  const uploadState = uploadStateRaw ?? {};
133
133
  const interruptedAt = new Date().toISOString();
134
+ const failureReason = retryableUploadInterruptionReason(interruptionDetail);
134
135
  if (!alreadyInterrupted) {
135
136
  const interrupted = await db
136
137
  .update(schema.recordings)
137
138
  .set({
138
139
  status: "failed",
139
- failureReason: RETRYABLE_UPLOAD_INTERRUPTION_REASON,
140
+ failureReason,
140
141
  updatedAt: interruptedAt,
141
142
  })
142
143
  .where(
@@ -167,7 +168,7 @@ export default defineEventHandler(async (event: H3Event) => {
167
168
  ...uploadState,
168
169
  recordingId,
169
170
  status: "failed",
170
- failureReason: RETRYABLE_UPLOAD_INTERRUPTION_REASON,
171
+ failureReason,
171
172
  retryableInterruption: true,
172
173
  uploadAttemptId: attemptId,
173
174
  uploadGenerationId,
@@ -55,8 +55,11 @@ import {
55
55
  } from "../../../../lib/recordings.js";
56
56
  import {
57
57
  deleteResumableSession,
58
+ getResumableSession,
58
59
  setResumableSession,
60
+ type StoredResumableSession,
59
61
  } from "../../../../lib/resumable-session.js";
62
+ import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
60
63
  import { shouldEnableStreamingUpload } from "../../../../lib/streaming-upload-mode.js";
61
64
  import {
62
65
  renewUploadLease,
@@ -72,6 +75,66 @@ interface CompressionMeta {
72
75
  outputMimeType?: string;
73
76
  }
74
77
 
78
+ interface PendingResumableCleanup {
79
+ recordingId: string;
80
+ generationId: string | null;
81
+ ownerGenerationId: string | null;
82
+ claimId: string | null;
83
+ session: StoredResumableSession;
84
+ }
85
+
86
+ function parsePendingResumableCleanup(
87
+ raw: Record<string, unknown> | null,
88
+ recordingId: string,
89
+ ): PendingResumableCleanup | null {
90
+ if (!raw) return null;
91
+ const session = raw.session;
92
+ const generationId = raw.generationId;
93
+ const ownerGenerationId = raw.ownerGenerationId;
94
+ const claimId = raw.claimId;
95
+ if (
96
+ raw.recordingId !== recordingId ||
97
+ !session ||
98
+ typeof session !== "object" ||
99
+ Array.isArray(session) ||
100
+ !(generationId === null || typeof generationId === "string") ||
101
+ (ownerGenerationId !== undefined &&
102
+ ownerGenerationId !== null &&
103
+ typeof ownerGenerationId !== "string") ||
104
+ (claimId !== undefined && claimId !== null && typeof claimId !== "string")
105
+ ) {
106
+ throw new Error(
107
+ `Invalid resumable cleanup state for recording ${recordingId}`,
108
+ );
109
+ }
110
+
111
+ const candidate = session as Record<string, unknown>;
112
+ if (
113
+ typeof candidate.providerId !== "string" ||
114
+ typeof candidate.sessionId !== "string" ||
115
+ !candidate.meta ||
116
+ typeof candidate.meta !== "object" ||
117
+ Array.isArray(candidate.meta) ||
118
+ typeof candidate.bytesUploaded !== "number" ||
119
+ !Number.isFinite(candidate.bytesUploaded) ||
120
+ (candidate.lastCommittedIndex !== undefined &&
121
+ typeof candidate.lastCommittedIndex !== "number")
122
+ ) {
123
+ throw new Error(
124
+ `Invalid resumable session in cleanup state for recording ${recordingId}`,
125
+ );
126
+ }
127
+
128
+ return {
129
+ recordingId,
130
+ generationId,
131
+ ownerGenerationId:
132
+ ownerGenerationId === undefined ? null : ownerGenerationId,
133
+ claimId: claimId === undefined ? null : claimId,
134
+ session: candidate as unknown as StoredResumableSession,
135
+ };
136
+ }
137
+
75
138
  function normalizeVideoMimeType(value: unknown): string | null {
76
139
  if (typeof value !== "string") return null;
77
140
  const mimeType = value.split(";")[0]?.trim().toLowerCase();
@@ -217,6 +280,25 @@ export default defineEventHandler(async (event: H3Event) => {
217
280
  const nextGenerationId = useGenerationFence ? randomUUID() : null;
218
281
  const uploadStateKey = `recording-upload-${recordingId}`;
219
282
  const uploadStateSnapshot = await readAppState(uploadStateKey);
283
+ const cleanupStateKey = `recording-resumable-cleanup-${recordingId}`;
284
+ const cleanupStateSnapshot = await readAppState(cleanupStateKey);
285
+ const parsedCleanup = parsePendingResumableCleanup(
286
+ cleanupStateSnapshot,
287
+ recordingId,
288
+ );
289
+ const pendingCleanup =
290
+ parsedCleanup &&
291
+ (parsedCleanup.ownerGenerationId === null
292
+ ? !useGenerationFence
293
+ : parsedCleanup.ownerGenerationId === existingGenerationId)
294
+ ? parsedCleanup
295
+ : null;
296
+ const discardedGenerationId = pendingCleanup
297
+ ? pendingCleanup.generationId
298
+ : existingGenerationId;
299
+ const discardedResumableSession =
300
+ pendingCleanup?.session ??
301
+ (await getResumableSession(recordingId, existingGenerationId));
220
302
  const reset = await db
221
303
  .update(schema.recordings)
222
304
  .set({
@@ -251,16 +333,63 @@ export default defineEventHandler(async (event: H3Event) => {
251
333
  };
252
334
  }
253
335
 
336
+ let cleanupClaim: PendingResumableCleanup | null = null;
337
+ if (discardedResumableSession) {
338
+ // A cleanup claim belongs to the generation that won this fence. A
339
+ // losing reset must never resurrect a claim after the winner releases
340
+ // it, and a later winner must be able to transfer an unfinished claim.
341
+ const candidate: PendingResumableCleanup = {
342
+ recordingId,
343
+ generationId: discardedGenerationId,
344
+ ownerGenerationId: nextGenerationId,
345
+ claimId: randomUUID(),
346
+ session: discardedResumableSession,
347
+ };
348
+ const claimed = await compareAndSetAppState(
349
+ cleanupStateKey,
350
+ cleanupStateSnapshot,
351
+ candidate as unknown as Record<string, unknown>,
352
+ );
353
+ if (claimed) cleanupClaim = candidate;
354
+ }
355
+
356
+ if (discardedResumableSession) {
357
+ if (cleanupClaim) {
358
+ const cleaned = await abortResumableUploadSession(
359
+ discardedResumableSession,
360
+ { label: `reset-${recordingId}` },
361
+ );
362
+ if (!cleaned) {
363
+ setResponseStatus(event, 502);
364
+ return {
365
+ error:
366
+ "The previous recording upload could not be cleaned up. Retry the upload restart.",
367
+ };
368
+ }
369
+ const released = await compareAndSetAppState(
370
+ cleanupStateKey,
371
+ cleanupClaim as unknown as Record<string, unknown>,
372
+ null,
373
+ );
374
+ if (!released) {
375
+ console.warn(
376
+ `[reset-chunks-${recordingId}] cleanup claim changed while releasing it`,
377
+ );
378
+ }
379
+ }
380
+ }
254
381
  const cleared = await deleteRecordingChunks(
255
382
  ownerEmail,
256
383
  recordingId,
257
- existingGenerationId,
384
+ discardedGenerationId,
258
385
  );
259
386
  // Clear any stale resumable session so a buffered retry does not
260
387
  // accidentally route through handleResumableChunk with stale offsets.
261
- await deleteResumableSession(recordingId, existingGenerationId).catch(
262
- () => {},
263
- );
388
+ if (!discardedResumableSession || cleanupClaim) {
389
+ await deleteResumableSession(recordingId, discardedGenerationId).catch(
390
+ () => {},
391
+ );
392
+ }
264
393
 
265
394
  let uploadMode: UploadMode = "buffered";
266
395
  let compensateStartedSession: (() => Promise<void>) | null = null;