@agent-native/core 0.109.0 → 0.109.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 (154) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/integrations.mdx +87 -253
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/a2a/client.ts +24 -2
  6. package/corpus/core/src/a2a/correlation.ts +50 -0
  7. package/corpus/core/src/a2a/handlers.ts +140 -14
  8. package/corpus/core/src/a2a/index.ts +1 -0
  9. package/corpus/core/src/a2a/invoke.ts +10 -1
  10. package/corpus/core/src/a2a/task-store.ts +146 -6
  11. package/corpus/core/src/a2a/types.ts +16 -0
  12. package/corpus/core/src/action.ts +2 -0
  13. package/corpus/core/src/agent/production-agent.ts +23 -0
  14. package/corpus/core/src/client/blocks/library/diagram.config.ts +10 -1
  15. package/corpus/core/src/client/blocks/library/diagram.tsx +9 -4
  16. package/corpus/core/src/client/use-run-stuck-detection.ts +27 -1
  17. package/corpus/core/src/db/client.ts +55 -0
  18. package/corpus/core/src/deploy/build.ts +63 -3
  19. package/corpus/core/src/observability/traces.ts +172 -19
  20. package/corpus/core/src/scripts/call-agent.ts +53 -2
  21. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +72 -25
  22. package/corpus/core/src/server/agent-chat-plugin.ts +107 -6
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  24. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  25. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  26. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  27. package/corpus/templates/analytics/changelog/2026-07-17-analytics-now-includes-a-default-alert-for-spikes-in-stuck-agent-chats.md +6 -0
  28. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +23 -14
  29. package/corpus/templates/analytics/docs/schemas/tracked-events.md +5 -1
  30. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +3 -3
  31. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +209 -87
  32. package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +28 -5
  33. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +7 -0
  34. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +23 -0
  35. package/corpus/templates/clips/AGENTS.md +10 -0
  36. package/corpus/templates/clips/actions/create-dictation.ts +29 -4
  37. package/corpus/templates/clips/actions/finalize-recording.ts +542 -65
  38. package/corpus/templates/clips/actions/get-recording-player-data.ts +16 -6
  39. package/corpus/templates/clips/actions/set-mobile-capture-state.ts +37 -0
  40. package/corpus/templates/clips/actions/update-dictation.ts +9 -1
  41. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +17 -2
  42. package/corpus/templates/clips/app/components/player/video-player.tsx +14 -2
  43. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +2 -2
  44. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +14 -3
  45. package/corpus/templates/clips/app/routes/record.tsx +3 -3
  46. package/corpus/templates/clips/app/routes/share.$shareId.tsx +19 -5
  47. package/corpus/templates/clips/changelog/2026-07-17-capture-dictation-background-meeting-audio-and-camera-videos.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-clips-now-retries-storage-verification-in-the-background-ins.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-17-delayed-upload-verification-keeps-local-backups-retryable.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-17-fixed-broken-thumbnail-icons-appearing-over-playable-clip-em.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-17-playback-comments-stay-readable-at-faster-playback-speeds.md +6 -0
  52. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +3 -0
  53. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -3
  54. package/corpus/templates/clips/desktop/src/app.tsx +35 -6
  55. package/corpus/templates/clips/desktop/src/lib/processing-backup-recovery.ts +19 -0
  56. package/corpus/templates/clips/desktop/src/lib/recorder.ts +141 -18
  57. package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +28 -7
  58. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -4
  59. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +32 -15
  60. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +59 -18
  61. package/corpus/templates/clips/server/db/schema.ts +9 -1
  62. package/corpus/templates/clips/server/jobs/media-verification.ts +130 -0
  63. package/corpus/templates/clips/server/lib/builder-media-compression.ts +74 -7
  64. package/corpus/templates/clips/server/lib/media-verification-state.ts +55 -0
  65. package/corpus/templates/clips/server/lib/player-thumbnail-url.ts +31 -0
  66. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -3
  67. package/corpus/templates/clips/server/plugins/auth.ts +5 -5
  68. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  69. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +12 -2
  70. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +13 -1
  71. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +340 -0
  72. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +24 -7
  73. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +91 -7
  74. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +22 -1
  75. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +14 -0
  76. package/corpus/templates/clips/shared/finalize-recovery.ts +61 -2
  77. package/corpus/templates/clips/shared/share-meta.ts +1 -1
  78. package/corpus/templates/plan/changelog/2026-07-17-diagrams-can-opt-into-a-polished-clean-rendering-mode-withou.md +6 -0
  79. package/corpus/templates/plan/shared/plan-content.ts +3 -0
  80. package/dist/a2a/client.d.ts +11 -2
  81. package/dist/a2a/client.d.ts.map +1 -1
  82. package/dist/a2a/client.js +16 -3
  83. package/dist/a2a/client.js.map +1 -1
  84. package/dist/a2a/correlation.d.ts +10 -0
  85. package/dist/a2a/correlation.d.ts.map +1 -0
  86. package/dist/a2a/correlation.js +40 -0
  87. package/dist/a2a/correlation.js.map +1 -0
  88. package/dist/a2a/handlers.d.ts.map +1 -1
  89. package/dist/a2a/handlers.js +100 -15
  90. package/dist/a2a/handlers.js.map +1 -1
  91. package/dist/a2a/index.d.ts +1 -1
  92. package/dist/a2a/index.d.ts.map +1 -1
  93. package/dist/a2a/index.js.map +1 -1
  94. package/dist/a2a/invoke.d.ts +4 -1
  95. package/dist/a2a/invoke.d.ts.map +1 -1
  96. package/dist/a2a/invoke.js +3 -0
  97. package/dist/a2a/invoke.js.map +1 -1
  98. package/dist/a2a/task-store.d.ts +12 -1
  99. package/dist/a2a/task-store.d.ts.map +1 -1
  100. package/dist/a2a/task-store.js +96 -6
  101. package/dist/a2a/task-store.js.map +1 -1
  102. package/dist/a2a/types.d.ts +15 -0
  103. package/dist/a2a/types.d.ts.map +1 -1
  104. package/dist/a2a/types.js.map +1 -1
  105. package/dist/action.d.ts +2 -0
  106. package/dist/action.d.ts.map +1 -1
  107. package/dist/action.js.map +1 -1
  108. package/dist/agent/production-agent.d.ts +9 -0
  109. package/dist/agent/production-agent.d.ts.map +1 -1
  110. package/dist/agent/production-agent.js +14 -0
  111. package/dist/agent/production-agent.js.map +1 -1
  112. package/dist/client/blocks/library/diagram.config.d.ts +2 -0
  113. package/dist/client/blocks/library/diagram.config.d.ts.map +1 -1
  114. package/dist/client/blocks/library/diagram.config.js +8 -1
  115. package/dist/client/blocks/library/diagram.config.js.map +1 -1
  116. package/dist/client/blocks/library/diagram.d.ts.map +1 -1
  117. package/dist/client/blocks/library/diagram.js +9 -4
  118. package/dist/client/blocks/library/diagram.js.map +1 -1
  119. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  120. package/dist/client/use-run-stuck-detection.js +25 -1
  121. package/dist/client/use-run-stuck-detection.js.map +1 -1
  122. package/dist/collab/routes.d.ts +1 -1
  123. package/dist/db/client.d.ts +7 -0
  124. package/dist/db/client.d.ts.map +1 -1
  125. package/dist/db/client.js +33 -0
  126. package/dist/db/client.js.map +1 -1
  127. package/dist/deploy/build.d.ts +3 -0
  128. package/dist/deploy/build.d.ts.map +1 -1
  129. package/dist/deploy/build.js +45 -1
  130. package/dist/deploy/build.js.map +1 -1
  131. package/dist/observability/routes.d.ts +1 -1
  132. package/dist/observability/traces.d.ts +9 -0
  133. package/dist/observability/traces.d.ts.map +1 -1
  134. package/dist/observability/traces.js +137 -19
  135. package/dist/observability/traces.js.map +1 -1
  136. package/dist/progress/routes.d.ts +1 -1
  137. package/dist/scripts/call-agent.d.ts.map +1 -1
  138. package/dist/scripts/call-agent.js +35 -3
  139. package/dist/scripts/call-agent.js.map +1 -1
  140. package/dist/server/agent-chat/action-filters-a2a.d.ts +18 -2
  141. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  142. package/dist/server/agent-chat/action-filters-a2a.js +35 -7
  143. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  144. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  145. package/dist/server/agent-chat-plugin.js +93 -6
  146. package/dist/server/agent-chat-plugin.js.map +1 -1
  147. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  148. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  149. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  150. package/docs/content/integrations.mdx +87 -253
  151. package/package.json +1 -1
  152. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  153. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  154. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
@@ -10,16 +10,17 @@
10
10
 
11
11
  import { defineAction } from "@agent-native/core";
12
12
  import {
13
+ compareAndSetAppState,
14
+ deleteAppState,
13
15
  readAppState,
14
16
  writeAppState,
15
- deleteAppState,
16
17
  } from "@agent-native/core/application-state";
17
18
  import { emit } from "@agent-native/core/event-bus";
18
19
  import { uploadFile } from "@agent-native/core/file-upload";
19
20
  import { captureRouteError } from "@agent-native/core/server";
20
21
  import { isStoredButUnservableFinalizeError } from "@shared/finalize-recovery.js";
21
22
  import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
22
- import { and, eq, isNull, ne } from "drizzle-orm";
23
+ import { and, eq, isNull } from "drizzle-orm";
23
24
  import { z } from "zod";
24
25
 
25
26
  import { getDb, schema } from "../server/db/index.js";
@@ -29,6 +30,10 @@ import {
29
30
  applyFaststart,
30
31
  hasPlayableMp4Metadata,
31
32
  } from "../server/lib/faststart.js";
33
+ import {
34
+ mediaVerificationStateKey,
35
+ parseMediaVerificationMarker,
36
+ } from "../server/lib/media-verification-state.js";
32
37
  import { dispatchPostFinalizeJob } from "../server/lib/post-finalize-dispatch.js";
33
38
  import {
34
39
  listRecordingChunkKeys,
@@ -97,6 +102,8 @@ const RECORDING_TOO_LARGE_REASON =
97
102
  const MEDIA_SERVE_VERIFICATION_TIMEOUT_MS = 8_000;
98
103
  const MEDIA_SERVE_VERIFICATION_ATTEMPTS = 3;
99
104
  const MEDIA_SERVE_VERIFICATION_BACKOFF_MS = 350;
105
+ const MEDIA_VERIFICATION_MAX_DURABLE_ATTEMPTS = 10;
106
+ const MEDIA_VERIFICATION_INITIAL_RETRY_DELAY_MS = 5_000;
100
107
 
101
108
  function stateNumber(
102
109
  value: Record<string, unknown> | null | undefined,
@@ -115,6 +122,14 @@ function stateBoolean(
115
122
  return typeof raw === "boolean" ? raw : undefined;
116
123
  }
117
124
 
125
+ function stateString(
126
+ value: Record<string, unknown> | null | undefined,
127
+ key: string,
128
+ ): string | undefined {
129
+ const raw = value?.[key];
130
+ return typeof raw === "string" && raw.trim() ? raw : undefined;
131
+ }
132
+
118
133
  const cliBoolean = z.preprocess((value) => {
119
134
  if (value === "true") return true;
120
135
  if (value === "false") return false;
@@ -166,11 +181,8 @@ function servedMediaSizeBytes(response: Response): number | null {
166
181
  return null;
167
182
  }
168
183
 
169
- async function verifyServedMediaUrl(
170
- videoUrl: string,
171
- expectedBytes: number,
172
- ): Promise<void> {
173
- if (!shouldVerifyServedMediaUrl(videoUrl)) return;
184
+ async function verifyServedMediaUrl(videoUrl: string): Promise<number | null> {
185
+ if (!shouldVerifyServedMediaUrl(videoUrl)) return null;
174
186
 
175
187
  let lastFailure = "media URL did not serve readable bytes";
176
188
  for (
@@ -192,12 +204,12 @@ async function verifyServedMediaUrl(
192
204
  const statusOk = response.status === 200 || response.status === 206;
193
205
  if (statusOk) {
194
206
  const servedBytes = servedMediaSizeBytes(response);
195
- if (servedBytes === null) {
196
- lastFailure = "Stored media byte count could not be verified";
197
- } else if (servedBytes !== expectedBytes) {
198
- lastFailure = `Stored media byte count mismatch (${servedBytes} of ${expectedBytes} bytes)`;
199
- } else if (await responseHasReadableMediaBytes(response)) {
200
- return;
207
+ if (await responseHasReadableMediaBytes(response)) {
208
+ // Builder stable video URLs intentionally replace the source object
209
+ // with a smaller compressed generation at the same URL. A positive,
210
+ // readable object is the invariant here; source-byte equality is
211
+ // verified separately by the upload receipt/local backup contract.
212
+ return servedBytes;
201
213
  } else {
202
214
  lastFailure = "media URL did not serve readable bytes";
203
215
  }
@@ -219,6 +231,13 @@ async function verifyServedMediaUrl(
219
231
  throw new Error(`Upload was stored-but-unservable: ${lastFailure}`);
220
232
  }
221
233
 
234
+ function mediaVerificationRetryDelayMs(attempt: number): number {
235
+ return Math.min(
236
+ 30_000,
237
+ MEDIA_VERIFICATION_INITIAL_RETRY_DELAY_MS * 2 ** Math.max(0, attempt - 1),
238
+ );
239
+ }
240
+
222
241
  function queueBackgroundBuilderCompression(args: {
223
242
  recordingId: string;
224
243
  ownerEmail: string;
@@ -241,11 +260,11 @@ async function failStoredButUnservableRecording(params: {
241
260
  id: string;
242
261
  ownerEmail: string;
243
262
  failureReason: string;
244
- }): Promise<void> {
263
+ }): Promise<boolean> {
245
264
  const { id, ownerEmail, failureReason } = params;
246
265
  const now = new Date().toISOString();
247
266
  const db = getDb();
248
- await db
267
+ const failed = await db
249
268
  .update(schema.recordings)
250
269
  .set({
251
270
  status: "failed",
@@ -256,8 +275,11 @@ async function failStoredButUnservableRecording(params: {
256
275
  and(
257
276
  eq(schema.recordings.id, id),
258
277
  ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
278
+ eq(schema.recordings.status, "processing"),
259
279
  ),
260
- );
280
+ )
281
+ .returning({ id: schema.recordings.id });
282
+ if (failed.length !== 1) return false;
261
283
  const uploadStateRaw = await readAppState(`recording-upload-${id}`).catch(
262
284
  () => null,
263
285
  );
@@ -272,7 +294,223 @@ async function failStoredButUnservableRecording(params: {
272
294
  failureReason,
273
295
  updatedAt: now,
274
296
  });
297
+ await deleteAppState(mediaVerificationStateKey(id)).catch((err) => {
298
+ console.warn("[finalize] failed to clear media verification marker", {
299
+ id,
300
+ error: err instanceof Error ? err.message : String(err),
301
+ });
302
+ });
275
303
  await writeAppState("refresh-signal", { ts: Date.now() });
304
+ return true;
305
+ }
306
+
307
+ type PendingMediaVerification = {
308
+ videoUrl: string;
309
+ videoSizeBytes: number;
310
+ sourceSizeBytes: number;
311
+ videoFormat: "webm" | "mp4";
312
+ finalDurationMs: number;
313
+ finalWidth: number;
314
+ finalHeight: number;
315
+ finalHasAudio: boolean;
316
+ finalHasCamera: boolean;
317
+ seekableApplied: boolean;
318
+ mimeType: string;
319
+ providerId?: string;
320
+ assetDbId?: string;
321
+ locallyTranscoded: boolean;
322
+ };
323
+
324
+ function pendingMediaVerificationFromState(
325
+ state: Record<string, unknown> | null,
326
+ ): PendingMediaVerification | null {
327
+ if (state?.pendingMediaVerification !== true) return null;
328
+ const videoUrl = stateString(state, "videoUrl");
329
+ const videoSizeBytes = stateNumber(state, "videoSizeBytes");
330
+ const sourceSizeBytes = stateNumber(state, "sourceSizeBytes");
331
+ const videoFormat = stateString(state, "videoFormat");
332
+ if (
333
+ !videoUrl ||
334
+ !videoSizeBytes ||
335
+ !sourceSizeBytes ||
336
+ (videoFormat !== "webm" && videoFormat !== "mp4")
337
+ ) {
338
+ return null;
339
+ }
340
+ return {
341
+ videoUrl,
342
+ videoSizeBytes,
343
+ sourceSizeBytes,
344
+ videoFormat,
345
+ finalDurationMs: stateNumber(state, "durationMs") ?? 0,
346
+ finalWidth: stateNumber(state, "width") ?? 0,
347
+ finalHeight: stateNumber(state, "height") ?? 0,
348
+ finalHasAudio: stateBoolean(state, "hasAudio") ?? true,
349
+ finalHasCamera: stateBoolean(state, "hasCamera") ?? false,
350
+ seekableApplied: stateBoolean(state, "seekableApplied") ?? false,
351
+ mimeType: stateString(state, "mimeType") ?? `video/${videoFormat}`,
352
+ providerId: stateString(state, "providerId"),
353
+ assetDbId: stateString(state, "assetDbId"),
354
+ locallyTranscoded: stateBoolean(state, "locallyTranscoded") ?? false,
355
+ };
356
+ }
357
+
358
+ async function persistPendingMediaVerification(params: {
359
+ id: string;
360
+ ownerEmail: string;
361
+ media: PendingMediaVerification;
362
+ failureReason: string;
363
+ retryAttempt?: number;
364
+ }): Promise<boolean> {
365
+ const { id, ownerEmail, media, failureReason } = params;
366
+ const now = new Date().toISOString();
367
+ const retryAttempt = Math.max(0, params.retryAttempt ?? 0);
368
+ const nextRetryAttempt = Math.min(
369
+ MEDIA_VERIFICATION_MAX_DURABLE_ATTEMPTS,
370
+ retryAttempt + 1,
371
+ );
372
+ const nextAttemptAt = new Date(
373
+ Date.now() + mediaVerificationRetryDelayMs(nextRetryAttempt),
374
+ ).toISOString();
375
+ const db = getDb();
376
+ const persisted = await db
377
+ .update(schema.recordings)
378
+ .set({
379
+ status: "processing",
380
+ videoUrl: media.videoUrl,
381
+ videoFormat: media.videoFormat,
382
+ videoSizeBytes: media.videoSizeBytes,
383
+ durationMs: media.finalDurationMs,
384
+ width: media.finalWidth,
385
+ height: media.finalHeight,
386
+ hasAudio: media.finalHasAudio,
387
+ hasCamera: media.finalHasCamera,
388
+ failureReason: null,
389
+ uploadProgress: 100,
390
+ updatedAt: now,
391
+ })
392
+ .where(
393
+ and(
394
+ eq(schema.recordings.id, id),
395
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
396
+ eq(schema.recordings.status, "processing"),
397
+ isNull(schema.recordings.trashedAt),
398
+ ),
399
+ )
400
+ .returning({ id: schema.recordings.id });
401
+ if (persisted.length !== 1) return false;
402
+
403
+ await writeAppState(`recording-upload-${id}`, {
404
+ recordingId: id,
405
+ status: "processing",
406
+ progress: 100,
407
+ pendingMediaVerification: true,
408
+ mediaVerificationAttempt: retryAttempt,
409
+ mediaVerificationNextAttemptAt: nextAttemptAt,
410
+ mediaVerificationLastError: failureReason,
411
+ videoUrl: media.videoUrl,
412
+ videoSizeBytes: media.videoSizeBytes,
413
+ sourceSizeBytes: media.sourceSizeBytes,
414
+ videoFormat: media.videoFormat,
415
+ durationMs: media.finalDurationMs,
416
+ width: media.finalWidth,
417
+ height: media.finalHeight,
418
+ hasAudio: media.finalHasAudio,
419
+ hasCamera: media.finalHasCamera,
420
+ seekableApplied: media.seekableApplied,
421
+ mimeType: media.mimeType,
422
+ providerId: media.providerId,
423
+ assetDbId: media.assetDbId,
424
+ locallyTranscoded: media.locallyTranscoded,
425
+ updatedAt: now,
426
+ });
427
+ await writeAppState(mediaVerificationStateKey(id), {
428
+ recordingId: id,
429
+ status: "pending",
430
+ completedAttempts: retryAttempt,
431
+ nextAttemptAt,
432
+ leaseUntil: null,
433
+ updatedAt: now,
434
+ });
435
+ await writeAppState("refresh-signal", { ts: Date.now() });
436
+ return true;
437
+ }
438
+
439
+ async function claimPendingMediaVerification(
440
+ id: string,
441
+ retryAttempt: number,
442
+ ): Promise<boolean> {
443
+ const key = mediaVerificationStateKey(id);
444
+ const raw = await readAppState(key).catch(() => null);
445
+ const marker = parseMediaVerificationMarker(raw);
446
+ const now = Date.now();
447
+ if (
448
+ !marker ||
449
+ marker.recordingId !== id ||
450
+ retryAttempt !== marker.completedAttempts + 1 ||
451
+ now < Date.parse(marker.nextAttemptAt) ||
452
+ (marker.status === "leased" &&
453
+ marker.leaseUntil !== null &&
454
+ now < Date.parse(marker.leaseUntil))
455
+ ) {
456
+ return false;
457
+ }
458
+
459
+ const updatedAt = new Date(now).toISOString();
460
+ return compareAndSetAppState(key, raw as Record<string, unknown>, {
461
+ ...marker,
462
+ status: "leased",
463
+ leaseUntil: new Date(now + 60_000).toISOString(),
464
+ updatedAt,
465
+ });
466
+ }
467
+
468
+ async function dispatchMediaVerificationRetry(
469
+ id: string,
470
+ retryAttempt: number,
471
+ ): Promise<void> {
472
+ await dispatchPostFinalizeJob({
473
+ recordingId: id,
474
+ kind: "media-ready",
475
+ delayMs: mediaVerificationRetryDelayMs(retryAttempt),
476
+ retryAttempt,
477
+ requireAccepted: true,
478
+ }).catch((err: unknown) => {
479
+ console.error("[finalize] media verification dispatch failed", {
480
+ id,
481
+ retryAttempt,
482
+ error: err instanceof Error ? err.message : String(err),
483
+ });
484
+ });
485
+ }
486
+
487
+ async function leaveRecordingProcessingForMediaVerification(params: {
488
+ id: string;
489
+ ownerEmail: string;
490
+ media: PendingMediaVerification;
491
+ failureReason: string;
492
+ }) {
493
+ const persisted = await persistPendingMediaVerification(params);
494
+ if (!persisted) {
495
+ return {
496
+ id: params.id,
497
+ status: "failed" as const,
498
+ videoUrl: params.media.videoUrl,
499
+ videoSizeBytes: params.media.videoSizeBytes,
500
+ sourceSizeBytes: params.media.sourceSizeBytes,
501
+ durationMs: params.media.finalDurationMs,
502
+ };
503
+ }
504
+ await dispatchMediaVerificationRetry(params.id, 1);
505
+ return {
506
+ id: params.id,
507
+ status: "processing" as const,
508
+ verificationPending: true,
509
+ videoUrl: params.media.videoUrl,
510
+ videoSizeBytes: params.media.videoSizeBytes,
511
+ sourceSizeBytes: params.media.sourceSizeBytes,
512
+ durationMs: params.media.finalDurationMs,
513
+ };
276
514
  }
277
515
 
278
516
  // Flip recording to 'ready', seed transcript row, fire background transcript,
@@ -313,7 +551,7 @@ async function markRecordingReady(params: {
313
551
  const db = getDb();
314
552
  const now = new Date().toISOString();
315
553
 
316
- await db
554
+ const promoted = await db
317
555
  .update(schema.recordings)
318
556
  .set({
319
557
  status: "ready",
@@ -333,12 +571,10 @@ async function markRecordingReady(params: {
333
571
  and(
334
572
  eq(schema.recordings.id, id),
335
573
  ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
336
- // Guard against a slow/racing finalize resurrecting a recording the
337
- // user already aborted (abort.post.ts flips status to 'failed'). If
338
- // the row was aborted after this call started, the WHERE clause
339
- // excludes it and the UPDATE becomes a no-op instead of silently
340
- // flipping 'failed' back to 'ready'.
341
- ne(schema.recordings.status, "failed"),
574
+ // The processing -> ready transition is the idempotency fence for
575
+ // duplicate finalize requests and durable verification workers. Only
576
+ // the winner performs transcript/event/post-finalize side effects.
577
+ eq(schema.recordings.status, "processing"),
342
578
  // Guard against the other direction of the cancel/finalize race:
343
579
  // trash-recording's skipIfReady only blocks trashing a row that is
344
580
  // ALREADY 'ready'. If cancel lands while this finalize is still
@@ -347,32 +583,30 @@ async function markRecordingReady(params: {
347
583
  // 'ready' underneath a recording the user just trashed.
348
584
  isNull(schema.recordings.trashedAt),
349
585
  ),
350
- );
586
+ )
587
+ .returning({ id: schema.recordings.id });
351
588
 
352
- // Re-select to see whether the guarded UPDATE above actually landed. If the
353
- // row is not 'ready' now, the status guard blocked the write (the recording
354
- // was aborted concurrently) — stop here without seeding a transcript,
355
- // writing 'ready' app-state, emitting clip.created, or kicking off
356
- // background transcription for a recording the user already saw fail.
357
- const [postUpdate] = await db
358
- .select({
359
- status: schema.recordings.status,
360
- })
361
- .from(schema.recordings)
362
- .where(
363
- and(
364
- eq(schema.recordings.id, id),
365
- ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
366
- ),
367
- );
368
- if (!postUpdate || postUpdate.status !== "ready") {
369
- debugLog(
370
- "[finalize] markRecordingReady blocked — recording was aborted concurrently",
371
- { id, status: postUpdate?.status },
372
- );
589
+ if (promoted.length !== 1) {
590
+ const [postUpdate] = await db
591
+ .select({ status: schema.recordings.status })
592
+ .from(schema.recordings)
593
+ .where(
594
+ and(
595
+ eq(schema.recordings.id, id),
596
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
597
+ ),
598
+ );
599
+ debugLog("[finalize] markRecordingReady transition already resolved", {
600
+ id,
601
+ status: postUpdate?.status,
602
+ });
373
603
  return {
374
604
  id,
375
- status: "failed" as const,
605
+ status:
606
+ postUpdate?.status === "ready"
607
+ ? ("ready" as const)
608
+ : ("failed" as const),
609
+ transitionedToReady: false,
376
610
  videoUrl,
377
611
  videoSizeBytes,
378
612
  sourceSizeBytes,
@@ -404,6 +638,12 @@ async function markRecordingReady(params: {
404
638
  durationMs: finalDurationMs,
405
639
  finishedAt: now,
406
640
  });
641
+ await deleteAppState(mediaVerificationStateKey(id)).catch((err) => {
642
+ console.warn("[finalize] failed to clear media verification marker", {
643
+ id,
644
+ error: err instanceof Error ? err.message : String(err),
645
+ });
646
+ });
407
647
  await writeAppState("refresh-signal", { ts: Date.now() });
408
648
 
409
649
  if (seekableApplied) {
@@ -464,6 +704,7 @@ async function markRecordingReady(params: {
464
704
  return {
465
705
  id,
466
706
  status: "ready" as const,
707
+ transitionedToReady: true,
467
708
  videoUrl,
468
709
  videoSizeBytes,
469
710
  sourceSizeBytes,
@@ -471,6 +712,165 @@ async function markRecordingReady(params: {
471
712
  };
472
713
  }
473
714
 
715
+ async function retryPendingMediaVerification(params: {
716
+ id: string;
717
+ ownerEmail: string;
718
+ existingTitle: string;
719
+ media: PendingMediaVerification;
720
+ retryAttempt: number;
721
+ }) {
722
+ const { id, ownerEmail, existingTitle, media, retryAttempt } = params;
723
+ const db = getDb();
724
+ const [recording] = await db
725
+ .select({
726
+ status: schema.recordings.status,
727
+ videoUrl: schema.recordings.videoUrl,
728
+ })
729
+ .from(schema.recordings)
730
+ .where(
731
+ and(
732
+ eq(schema.recordings.id, id),
733
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
734
+ ),
735
+ );
736
+ if (!recording || recording.status !== "processing") {
737
+ return {
738
+ id,
739
+ status:
740
+ recording?.status === "ready"
741
+ ? ("ready" as const)
742
+ : ("failed" as const),
743
+ videoUrl: recording?.videoUrl ?? media.videoUrl,
744
+ videoSizeBytes: media.videoSizeBytes,
745
+ sourceSizeBytes: media.sourceSizeBytes,
746
+ durationMs: media.finalDurationMs,
747
+ };
748
+ }
749
+
750
+ const candidate: PendingMediaVerification = {
751
+ ...media,
752
+ videoUrl: recording.videoUrl || media.videoUrl,
753
+ };
754
+ try {
755
+ const servedBytes = await verifyServedMediaUrl(candidate.videoUrl);
756
+ const result = await markRecordingReady({
757
+ id,
758
+ ownerEmail,
759
+ existingTitle,
760
+ videoUrl: candidate.videoUrl,
761
+ videoSizeBytes: servedBytes ?? candidate.videoSizeBytes,
762
+ sourceSizeBytes: candidate.sourceSizeBytes,
763
+ videoFormat: candidate.videoFormat,
764
+ finalDurationMs: candidate.finalDurationMs,
765
+ finalWidth: candidate.finalWidth,
766
+ finalHeight: candidate.finalHeight,
767
+ finalHasAudio: candidate.finalHasAudio,
768
+ finalHasCamera: candidate.finalHasCamera,
769
+ seekableApplied: candidate.seekableApplied,
770
+ });
771
+ if (result.status === "ready" && result.transitionedToReady) {
772
+ queueBackgroundBuilderCompression({
773
+ recordingId: id,
774
+ ownerEmail,
775
+ videoUrl: candidate.videoUrl,
776
+ mimeType: candidate.mimeType,
777
+ providerId: candidate.providerId,
778
+ assetDbId: candidate.assetDbId,
779
+ sourceSizeBytes: candidate.videoSizeBytes,
780
+ locallyTranscoded: candidate.locallyTranscoded,
781
+ });
782
+ }
783
+ return result;
784
+ } catch (err) {
785
+ const failureReason = err instanceof Error ? err.message : String(err);
786
+ if (retryAttempt >= MEDIA_VERIFICATION_MAX_DURABLE_ATTEMPTS) {
787
+ const terminalReason = `${failureReason} after ${retryAttempt} durable verification attempts`;
788
+ const failed = await failStoredButUnservableRecording({
789
+ id,
790
+ ownerEmail,
791
+ failureReason: terminalReason,
792
+ });
793
+ if (!failed) {
794
+ const [resolved] = await db
795
+ .select({
796
+ status: schema.recordings.status,
797
+ videoUrl: schema.recordings.videoUrl,
798
+ })
799
+ .from(schema.recordings)
800
+ .where(
801
+ and(
802
+ eq(schema.recordings.id, id),
803
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
804
+ ),
805
+ );
806
+ return {
807
+ id,
808
+ status:
809
+ resolved?.status === "ready"
810
+ ? ("ready" as const)
811
+ : ("failed" as const),
812
+ videoUrl: resolved?.videoUrl ?? candidate.videoUrl,
813
+ videoSizeBytes: candidate.videoSizeBytes,
814
+ sourceSizeBytes: candidate.sourceSizeBytes,
815
+ durationMs: candidate.finalDurationMs,
816
+ };
817
+ }
818
+ return {
819
+ id,
820
+ status: "failed" as const,
821
+ videoUrl: candidate.videoUrl,
822
+ videoSizeBytes: candidate.videoSizeBytes,
823
+ sourceSizeBytes: candidate.sourceSizeBytes,
824
+ durationMs: candidate.finalDurationMs,
825
+ failureReason: terminalReason,
826
+ };
827
+ }
828
+
829
+ const persisted = await persistPendingMediaVerification({
830
+ id,
831
+ ownerEmail,
832
+ media: candidate,
833
+ failureReason,
834
+ retryAttempt,
835
+ });
836
+ if (!persisted) {
837
+ const [resolved] = await db
838
+ .select({
839
+ status: schema.recordings.status,
840
+ videoUrl: schema.recordings.videoUrl,
841
+ })
842
+ .from(schema.recordings)
843
+ .where(
844
+ and(
845
+ eq(schema.recordings.id, id),
846
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
847
+ ),
848
+ );
849
+ return {
850
+ id,
851
+ status:
852
+ resolved?.status === "ready"
853
+ ? ("ready" as const)
854
+ : ("failed" as const),
855
+ videoUrl: resolved?.videoUrl ?? candidate.videoUrl,
856
+ videoSizeBytes: candidate.videoSizeBytes,
857
+ sourceSizeBytes: candidate.sourceSizeBytes,
858
+ durationMs: candidate.finalDurationMs,
859
+ };
860
+ }
861
+ await dispatchMediaVerificationRetry(id, retryAttempt + 1);
862
+ return {
863
+ id,
864
+ status: "processing" as const,
865
+ verificationPending: true,
866
+ videoUrl: candidate.videoUrl,
867
+ videoSizeBytes: candidate.videoSizeBytes,
868
+ sourceSizeBytes: candidate.sourceSizeBytes,
869
+ durationMs: candidate.finalDurationMs,
870
+ };
871
+ }
872
+ }
873
+
474
874
  export default defineAction({
475
875
  description:
476
876
  "Assemble recorded chunks into a final video blob, upload it to the configured storage provider, update the recording row (videoUrl, durationMs, width/height/hasAudio/hasCamera), flip status to 'ready', and trigger the agent to produce a title, summary, transcript, and chapters in the background.",
@@ -499,6 +899,7 @@ export default defineAction({
499
899
  .describe(
500
900
  "Whether the uploaded video bytes were already locally transcoded/compressed before upload",
501
901
  ),
902
+ mediaVerificationRetryAttempt: z.number().int().min(1).max(10).optional(),
502
903
  }),
503
904
  run: async (args) => {
504
905
  const db = getDb();
@@ -547,6 +948,7 @@ export default defineAction({
547
948
  await deleteResumableSession(id).catch((err) =>
548
949
  console.warn("[finalize] failed to delete resumable session:", err),
549
950
  );
951
+ await deleteAppState(mediaVerificationStateKey(id)).catch(() => {});
550
952
  return {
551
953
  id,
552
954
  status: "ready" as const,
@@ -605,6 +1007,33 @@ export default defineAction({
605
1007
  existingTitle: existing.title,
606
1008
  };
607
1009
 
1010
+ const pendingMedia = pendingMediaVerificationFromState(uploadState);
1011
+ if (existing.status === "processing" && pendingMedia) {
1012
+ const retryAttempt =
1013
+ args.mediaVerificationRetryAttempt ??
1014
+ stateNumber(uploadState, "mediaVerificationAttempt") ??
1015
+ 1;
1016
+ const claimed = await claimPendingMediaVerification(id, retryAttempt);
1017
+ if (!claimed) {
1018
+ return {
1019
+ id,
1020
+ status: "processing" as const,
1021
+ verificationPending: true,
1022
+ videoUrl: pendingMedia.videoUrl,
1023
+ videoSizeBytes: pendingMedia.videoSizeBytes,
1024
+ sourceSizeBytes: pendingMedia.sourceSizeBytes,
1025
+ durationMs: pendingMedia.finalDurationMs,
1026
+ };
1027
+ }
1028
+ return retryPendingMediaVerification({
1029
+ id,
1030
+ ownerEmail,
1031
+ existingTitle: existing.title,
1032
+ media: pendingMedia,
1033
+ retryAttempt,
1034
+ });
1035
+ }
1036
+
608
1037
  // Resumable path: create-recording initialized a session and chunk.post.ts
609
1038
  // forwarded all chunks to the provider. Complete the session to get the CDN URL.
610
1039
  const resumableSession = await getResumableSession(id);
@@ -651,6 +1080,24 @@ export default defineAction({
651
1080
  updatedAt: recoveryStartedAt,
652
1081
  });
653
1082
  }
1083
+ if (existing.status !== "processing" && existing.status !== "failed") {
1084
+ const processingStartedAt = new Date().toISOString();
1085
+ await db
1086
+ .update(schema.recordings)
1087
+ .set({
1088
+ status: "processing",
1089
+ failureReason: null,
1090
+ uploadProgress: 100,
1091
+ updatedAt: processingStartedAt,
1092
+ })
1093
+ .where(
1094
+ and(
1095
+ eq(schema.recordings.id, id),
1096
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
1097
+ eq(schema.recordings.status, existing.status),
1098
+ ),
1099
+ );
1100
+ }
654
1101
  try {
655
1102
  const uploadProvider = await resolveResumableUploadProvider(
656
1103
  resumableSession.providerId,
@@ -672,32 +1119,51 @@ export default defineAction({
672
1119
  { stableUrl: true, recordAsset: false },
673
1120
  );
674
1121
  debugLog("[finalize] resumable upload completed", { id, videoUrl });
1122
+ let servedBytes: number | null;
675
1123
  try {
676
- await verifyServedMediaUrl(
677
- videoUrl,
678
- resumableSession.bytesUploaded,
679
- );
1124
+ servedBytes = await verifyServedMediaUrl(videoUrl);
680
1125
  } catch (err) {
681
1126
  const failureReason =
682
1127
  err instanceof Error ? err.message : String(err);
683
- await failStoredButUnservableRecording({
1128
+ const pending = await leaveRecordingProcessingForMediaVerification({
684
1129
  id,
685
1130
  ownerEmail,
686
1131
  failureReason,
1132
+ media: {
1133
+ videoUrl,
1134
+ videoSizeBytes: resumableSession.bytesUploaded,
1135
+ sourceSizeBytes: resumableSession.bytesUploaded,
1136
+ videoFormat,
1137
+ finalDurationMs,
1138
+ finalWidth,
1139
+ finalHeight,
1140
+ finalHasAudio,
1141
+ finalHasCamera,
1142
+ seekableApplied: false,
1143
+ mimeType,
1144
+ providerId: resumableSession.providerId,
1145
+ locallyTranscoded: args.locallyTranscoded === true,
1146
+ },
687
1147
  });
688
- throw err;
1148
+ await deleteResumableSession(id).catch((deleteErr) =>
1149
+ console.warn(
1150
+ "[finalize] failed to retire pending resumable session:",
1151
+ deleteErr,
1152
+ ),
1153
+ );
1154
+ return pending;
689
1155
  }
690
1156
  const result = await markRecordingReady({
691
1157
  ...readyParams,
692
1158
  videoUrl,
693
- videoSizeBytes: resumableSession.bytesUploaded,
1159
+ videoSizeBytes: servedBytes ?? resumableSession.bytesUploaded,
694
1160
  sourceSizeBytes: resumableSession.bytesUploaded,
695
1161
  // Streaming path forwards raw MediaRecorder bytes straight to the
696
1162
  // provider — no faststart/Cues rewrite happened. Repair in the
697
1163
  // background.
698
1164
  seekableApplied: false,
699
1165
  });
700
- if (result.status === "ready") {
1166
+ if (result.status === "ready" && result.transitionedToReady) {
701
1167
  queueBackgroundBuilderCompression({
702
1168
  recordingId: id,
703
1169
  ownerEmail,
@@ -1197,22 +1663,33 @@ export default defineAction({
1197
1663
  videoUrl: upload.url,
1198
1664
  bytes: uploadData.byteLength,
1199
1665
  });
1666
+ let servedBytes: number | null;
1200
1667
  try {
1201
- await verifyServedMediaUrl(upload.url, uploadData.byteLength);
1668
+ servedBytes = await verifyServedMediaUrl(upload.url);
1202
1669
  } catch (err) {
1203
- if (!requiresConfiguredVideoStorage()) {
1204
- // Local dev can keep scratch chunks so the user can retry after fixing
1205
- // a local storage/server issue. Hosted SQL must not keep video blobs in
1206
- // application_state after a provider returned an unservable URL.
1207
- chunkKeysToPurge = [];
1208
- }
1209
1670
  const failureReason = err instanceof Error ? err.message : String(err);
1210
- await failStoredButUnservableRecording({
1671
+ return await leaveRecordingProcessingForMediaVerification({
1211
1672
  id,
1212
1673
  ownerEmail,
1213
1674
  failureReason,
1675
+ media: {
1676
+ videoUrl: upload.url,
1677
+ videoSizeBytes: uploadData.byteLength,
1678
+ sourceSizeBytes: assembled.byteLength,
1679
+ videoFormat,
1680
+ finalDurationMs,
1681
+ finalWidth,
1682
+ finalHeight,
1683
+ finalHasAudio: correctedHasAudio,
1684
+ finalHasCamera,
1685
+ seekableApplied,
1686
+ mimeType,
1687
+ providerId: upload.provider,
1688
+ assetDbId: upload.id,
1689
+ locallyTranscoded:
1690
+ args.locallyTranscoded === true || Boolean(compressionMeta),
1691
+ },
1214
1692
  });
1215
- throw err;
1216
1693
  }
1217
1694
  const result = await markRecordingReady({
1218
1695
  ...readyParams,
@@ -1220,11 +1697,11 @@ export default defineAction({
1220
1697
  // `readyParams` — see the audio sanity check above.
1221
1698
  finalHasAudio: correctedHasAudio,
1222
1699
  videoUrl: upload.url,
1223
- videoSizeBytes: uploadData.byteLength,
1700
+ videoSizeBytes: servedBytes ?? uploadData.byteLength,
1224
1701
  sourceSizeBytes: assembled.byteLength,
1225
1702
  seekableApplied,
1226
1703
  });
1227
- if (result.status === "ready") {
1704
+ if (result.status === "ready" && result.transitionedToReady) {
1228
1705
  queueBackgroundBuilderCompression({
1229
1706
  recordingId: id,
1230
1707
  ownerEmail,