@agent-native/core 0.109.0 → 0.109.1

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