@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
@@ -0,0 +1,130 @@
1
+ import { getDbExec } from "@agent-native/core/db";
2
+ import { runWithRequestContext } from "@agent-native/core/server";
3
+ import { and, eq, isNull } from "drizzle-orm";
4
+
5
+ import finalizeRecording from "../../actions/finalize-recording.js";
6
+ import { getDb, schema } from "../db/index.js";
7
+ import {
8
+ MEDIA_VERIFICATION_STATE_PREFIX,
9
+ parseMediaVerificationMarker,
10
+ } from "../lib/media-verification-state.js";
11
+ import { ownerEmailMatches } from "../lib/recordings.js";
12
+
13
+ const SWEEP_INTERVAL_MS = 60_000;
14
+ const DISPATCH_FALLBACK_GRACE_MS = 30_000;
15
+ const MAX_ATTEMPTS = 10;
16
+ let skippingLogged = false;
17
+
18
+ export async function runMediaVerificationSweepOnce(): Promise<void> {
19
+ const { rows } = await getDbExec().execute({
20
+ sql: `SELECT session_id, key, value FROM application_state WHERE key LIKE ?`,
21
+ args: [`${MEDIA_VERIFICATION_STATE_PREFIX}%`],
22
+ });
23
+ const now = Date.now();
24
+
25
+ for (const row of rows as Array<{
26
+ session_id?: unknown;
27
+ key?: unknown;
28
+ value?: unknown;
29
+ }>) {
30
+ const sessionId =
31
+ typeof row.session_id === "string" ? row.session_id.trim() : "";
32
+ const key = typeof row.key === "string" ? row.key : "";
33
+ const recordingId = key.startsWith(MEDIA_VERIFICATION_STATE_PREFIX)
34
+ ? key.slice(MEDIA_VERIFICATION_STATE_PREFIX.length)
35
+ : "";
36
+ const rawValue = typeof row.value === "string" ? row.value : "";
37
+ let rawState: unknown;
38
+ try {
39
+ rawState = JSON.parse(rawValue);
40
+ } catch {
41
+ continue;
42
+ }
43
+ const marker = parseMediaVerificationMarker(rawState);
44
+ if (
45
+ !sessionId ||
46
+ !recordingId ||
47
+ !marker ||
48
+ marker.recordingId !== recordingId
49
+ ) {
50
+ continue;
51
+ }
52
+
53
+ const nextAttemptAt = Date.parse(marker.nextAttemptAt);
54
+ const leaseUntil = marker.leaseUntil
55
+ ? Date.parse(marker.leaseUntil)
56
+ : Number.NEGATIVE_INFINITY;
57
+ const due =
58
+ marker.status === "pending"
59
+ ? now >= nextAttemptAt + DISPATCH_FALLBACK_GRACE_MS
60
+ : now >= leaseUntil;
61
+ if (marker.completedAttempts >= MAX_ATTEMPTS || !due) {
62
+ continue;
63
+ }
64
+
65
+ try {
66
+ const [recording] = await getDb()
67
+ .select({
68
+ ownerEmail: schema.recordings.ownerEmail,
69
+ orgId: schema.recordings.orgId,
70
+ })
71
+ .from(schema.recordings)
72
+ .where(
73
+ and(
74
+ eq(schema.recordings.id, recordingId),
75
+ ownerEmailMatches(schema.recordings.ownerEmail, sessionId),
76
+ eq(schema.recordings.status, "processing"),
77
+ isNull(schema.recordings.trashedAt),
78
+ ),
79
+ )
80
+ .limit(1);
81
+ if (!recording) continue;
82
+
83
+ await runWithRequestContext(
84
+ {
85
+ userEmail: recording.ownerEmail,
86
+ orgId: recording.orgId ?? undefined,
87
+ },
88
+ async () => {
89
+ await finalizeRecording.run({
90
+ id: recordingId,
91
+ mediaVerificationRetryAttempt: Math.min(
92
+ MAX_ATTEMPTS,
93
+ marker.completedAttempts + 1,
94
+ ),
95
+ });
96
+ },
97
+ );
98
+ } catch (err) {
99
+ console.warn("[media-verification] sweep item failed", {
100
+ key: String(row.key ?? ""),
101
+ recordingId,
102
+ error: err instanceof Error ? err.message : String(err),
103
+ });
104
+ }
105
+ }
106
+ }
107
+
108
+ export default function registerMediaVerificationJob(): void {
109
+ const isProd = process.env.NODE_ENV === "production";
110
+ const flag = process.env.RUN_BACKGROUND_JOBS;
111
+ const enabled = flag === "1" || (isProd && flag !== "0");
112
+ if (!enabled) {
113
+ if (process.env.DEBUG && !skippingLogged) {
114
+ console.log(
115
+ "[media-verification] Skipping background sweep (set RUN_BACKGROUND_JOBS=1 to enable in dev).",
116
+ );
117
+ skippingLogged = true;
118
+ }
119
+ return;
120
+ }
121
+
122
+ setInterval(() => {
123
+ runMediaVerificationSweepOnce().catch((err) =>
124
+ console.error("[media-verification] interval failed:", err),
125
+ );
126
+ }, SWEEP_INTERVAL_MS);
127
+ console.log(
128
+ `[media-verification] Recurring recovery sweep every ${SWEEP_INTERVAL_MS / 1000}s.`,
129
+ );
130
+ }
@@ -9,7 +9,7 @@ import {
9
9
  resolveBuilderPrivateKey,
10
10
  runWithRequestContext,
11
11
  } from "@agent-native/core/server";
12
- import { and, eq } from "drizzle-orm";
12
+ import { and, eq, isNull } from "drizzle-orm";
13
13
 
14
14
  import type { MediaWorkerCallback } from "../../shared/media-worker-contract.js";
15
15
  import { getDb, schema } from "../db/index.js";
@@ -648,6 +648,21 @@ async function processCompressionState(
648
648
  try {
649
649
  const compressed = await triggerBuilderCompression(state);
650
650
  if (compressed) {
651
+ if (!compressed.durationMs) {
652
+ if (attempts >= MAX_TRIGGER_ATTEMPTS) {
653
+ return markCompressionFailed(
654
+ { ...state, attempts },
655
+ `Builder media compression did not provide a verified duration after ${attempts} attempts`,
656
+ );
657
+ }
658
+ return writeCompressionState(state, {
659
+ status: "triggered",
660
+ attempts,
661
+ lastTriggeredAt: new Date().toISOString(),
662
+ nextAttemptAt: isoAfter(TRIGGERED_POLL_DELAY_MS),
663
+ detail: "Compressed media is not ready with a verified duration yet",
664
+ });
665
+ }
651
666
  return swapRecordingToCompressed(
652
667
  state,
653
668
  compressed.url,
@@ -943,7 +958,12 @@ export async function runBuilderMediaCompressionSweepOnce(): Promise<void> {
943
958
  key?: unknown;
944
959
  value?: unknown;
945
960
  }>) {
946
- const sessionId = typeof row.session_id === "string" ? row.session_id : "";
961
+ const sessionId =
962
+ typeof row.session_id === "string" ? row.session_id.trim() : "";
963
+ const key = typeof row.key === "string" ? row.key : "";
964
+ const recordingId = key.startsWith(BUILDER_MEDIA_COMPRESSION_STATE_PREFIX)
965
+ ? key.slice(BUILDER_MEDIA_COMPRESSION_STATE_PREFIX.length)
966
+ : "";
947
967
  const rawValue = typeof row.value === "string" ? row.value : "";
948
968
  let value: Record<string, unknown> | null = null;
949
969
  try {
@@ -952,14 +972,61 @@ export async function runBuilderMediaCompressionSweepOnce(): Promise<void> {
952
972
  continue;
953
973
  }
954
974
  const state = parseState(value);
955
- if (!state || isFinalStatus(state.status) || !isDue(state)) continue;
956
- const ownerEmail = state.ownerEmail || sessionId;
957
- if (!ownerEmail) continue;
975
+ if (
976
+ !sessionId ||
977
+ !recordingId ||
978
+ !state ||
979
+ state.recordingId !== recordingId ||
980
+ isFinalStatus(state.status) ||
981
+ !isDue(state)
982
+ ) {
983
+ continue;
984
+ }
958
985
  try {
986
+ const [recording] = await getDb()
987
+ .select({
988
+ ownerEmail: schema.recordings.ownerEmail,
989
+ orgId: schema.recordings.orgId,
990
+ videoUrl: schema.recordings.videoUrl,
991
+ })
992
+ .from(schema.recordings)
993
+ .where(
994
+ and(
995
+ eq(schema.recordings.id, recordingId),
996
+ ownerEmailMatches(schema.recordings.ownerEmail, sessionId),
997
+ eq(schema.recordings.status, "ready"),
998
+ isNull(schema.recordings.trashedAt),
999
+ ),
1000
+ )
1001
+ .limit(1);
1002
+ const expectedTarget = extractBuilderMediaTarget(
1003
+ recording?.videoUrl ?? "",
1004
+ );
1005
+ if (
1006
+ !recording ||
1007
+ !expectedTarget ||
1008
+ state.sourceUrl !== expectedTarget.sourceUrl ||
1009
+ (state.mediaWorker &&
1010
+ (state.mediaWorker.jobId !==
1011
+ mediaWorkerCompressionJobId(recordingId) ||
1012
+ state.mediaWorker.outputUrl !== expectedTarget.compressedUrl))
1013
+ ) {
1014
+ continue;
1015
+ }
1016
+
1017
+ const trustedState: BuilderMediaCompressionState = {
1018
+ ...state,
1019
+ ...expectedTarget,
1020
+ ownerEmail: recording.ownerEmail,
1021
+ orgId: recording.orgId,
1022
+ };
959
1023
  await runWithRequestContext(
960
- { userEmail: ownerEmail, orgId: state.orgId ?? undefined },
1024
+ {
1025
+ userEmail: recording.ownerEmail,
1026
+ orgId: recording.orgId ?? undefined,
1027
+ },
961
1028
  async () => {
962
- await processCompressionState({ ...state, ownerEmail });
1029
+ await processCompressionState(trustedState);
963
1030
  },
964
1031
  );
965
1032
  } catch (err) {
@@ -0,0 +1,55 @@
1
+ import { appStateGet } from "@agent-native/core/application-state";
2
+
3
+ export const MEDIA_VERIFICATION_STATE_PREFIX = "recording-media-verification-";
4
+
5
+ export type MediaVerificationMarker = {
6
+ recordingId: string;
7
+ status: "pending" | "leased";
8
+ completedAttempts: number;
9
+ nextAttemptAt: string;
10
+ leaseUntil: string | null;
11
+ updatedAt: string;
12
+ };
13
+
14
+ export function mediaVerificationStateKey(recordingId: string): string {
15
+ return `${MEDIA_VERIFICATION_STATE_PREFIX}${recordingId}`;
16
+ }
17
+
18
+ export function parseMediaVerificationMarker(
19
+ value: unknown,
20
+ ): MediaVerificationMarker | null {
21
+ if (!value || typeof value !== "object") return null;
22
+ const state = value as Record<string, unknown>;
23
+ if (
24
+ typeof state.recordingId !== "string" ||
25
+ !state.recordingId ||
26
+ (state.status !== "pending" && state.status !== "leased") ||
27
+ typeof state.completedAttempts !== "number" ||
28
+ !Number.isInteger(state.completedAttempts) ||
29
+ state.completedAttempts < 0 ||
30
+ typeof state.nextAttemptAt !== "string" ||
31
+ !Number.isFinite(Date.parse(state.nextAttemptAt)) ||
32
+ (state.leaseUntil !== null &&
33
+ (typeof state.leaseUntil !== "string" ||
34
+ !Number.isFinite(Date.parse(state.leaseUntil)))) ||
35
+ typeof state.updatedAt !== "string"
36
+ ) {
37
+ return null;
38
+ }
39
+ return state as MediaVerificationMarker;
40
+ }
41
+
42
+ export async function isMediaVerificationPending(args: {
43
+ ownerEmail: string;
44
+ recordingId: string;
45
+ recordingStatus: string;
46
+ }): Promise<boolean> {
47
+ if (args.recordingStatus !== "processing") return false;
48
+
49
+ const value = await appStateGet(
50
+ args.ownerEmail,
51
+ mediaVerificationStateKey(args.recordingId),
52
+ ).catch(() => null);
53
+ const marker = parseMediaVerificationMarker(value);
54
+ return marker?.recordingId === args.recordingId;
55
+ }
@@ -0,0 +1,31 @@
1
+ export type PlayerThumbnailRecording = {
2
+ id: string;
3
+ thumbnailUrl?: string | null;
4
+ animatedThumbnailUrl?: string | null;
5
+ };
6
+
7
+ function appendQueryParam(url: string, key: string, value: string): string {
8
+ const separator = url.includes("?") ? "&" : "?";
9
+ return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
10
+ }
11
+
12
+ export function localRecordingThumbnailRoute(recordingId: string): string {
13
+ return `/api/thumbnail/${encodeURIComponent(recordingId)}`;
14
+ }
15
+
16
+ export function resolvePlayerThumbnailUrl(
17
+ recording: PlayerThumbnailRecording,
18
+ options: {
19
+ accessToken?: string | null;
20
+ appPath?: (path: string) => string;
21
+ } = {},
22
+ ): string | null {
23
+ if (!recording.thumbnailUrl && !recording.animatedThumbnailUrl) return null;
24
+
25
+ let resolved = localRecordingThumbnailRoute(recording.id);
26
+ if (options.accessToken) {
27
+ resolved = appendQueryParam(resolved, "t", options.accessToken);
28
+ }
29
+ if (options.appPath) resolved = options.appPath(resolved);
30
+ return resolved;
31
+ }
@@ -7,7 +7,7 @@ import {
7
7
  signScopedAgentAccessToken,
8
8
  } from "@agent-native/core/server";
9
9
 
10
- export type PostFinalizeJobKind = "seekable" | "transcript";
10
+ export type PostFinalizeJobKind = "media-ready" | "seekable" | "transcript";
11
11
 
12
12
  export const POST_FINALIZE_JOB_TOKEN_KIND = "clips-post-finalize-job";
13
13
 
@@ -47,10 +47,12 @@ export async function dispatchPostFinalizeJob(args: {
47
47
  delayMs?: number;
48
48
  retryAttempt?: number;
49
49
  regenerate?: boolean;
50
+ requireAccepted?: boolean;
50
51
  }): Promise<void> {
52
+ const { requireAccepted = false, ...job } = args;
51
53
  const token = signScopedAgentAccessToken({
52
54
  resourceKind: POST_FINALIZE_JOB_TOKEN_KIND,
53
- resourceId: postFinalizeJobResourceId(args.recordingId, args.kind),
55
+ resourceId: postFinalizeJobResourceId(job.recordingId, job.kind),
54
56
  ttlSeconds: 10 * 60,
55
57
  });
56
58
  const basePath = normalizeBasePath(
@@ -62,7 +64,7 @@ export async function dispatchPostFinalizeJob(args: {
62
64
  const usesDurableBackground =
63
65
  dispatchPathTargetsNetlifyBackgroundFunction(dispatchPath);
64
66
  const body = JSON.stringify({
65
- ...args,
67
+ ...job,
66
68
  token,
67
69
  ...(usesDurableBackground
68
70
  ? {
@@ -100,6 +102,11 @@ export async function dispatchPostFinalizeJob(args: {
100
102
  });
101
103
  });
102
104
 
105
+ if (requireAccepted) {
106
+ await dispatch;
107
+ return;
108
+ }
109
+
103
110
  await Promise.race([
104
111
  dispatch,
105
112
  new Promise<void>((resolve) => setTimeout(resolve, DISPATCH_SETTLE_MS)),
@@ -37,12 +37,12 @@ export default createAuthPlugin({
37
37
  "/api/media",
38
38
  "/api/clips-latest.json",
39
39
  "/api/clips-updater.json",
40
- // Blob-serving for the dev-fallback (no provider) path.
41
- // The route itself enforces resolveAccess + password/expiry checks;
42
- // we add it to publicPaths so anonymous viewers on /share/:id can
43
- // actually fetch the <video> bytes for public recordings. The chunk
44
- // upload POSTs stay behind auth under /api/uploads/*.
40
+ // Public media-serving routes enforce resolveAccess + password/expiry
41
+ // checks themselves. They need to bypass the app auth shell so anonymous
42
+ // viewers and crawlers can fetch public share media. Chunk upload POSTs
43
+ // stay behind auth under /api/uploads/*.
45
44
  "/api/video",
45
+ "/api/thumbnail",
46
46
  "/api/auth/google-calendar",
47
47
  "/_agent-native/google/auth-url",
48
48
  "/_agent-native/google/callback",
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import registerBuilderMediaCompressionJob from "../jobs/builder-media-compression.js";
9
+ import registerMediaVerificationJob from "../jobs/media-verification.js";
9
10
  import registerMeetingRemindersJob from "../jobs/meeting-reminders.js";
10
11
  import registerPollCalendarsJob from "../jobs/poll-calendars.js";
11
12
  import registerStaleMeetingSweeperJob from "../jobs/stale-meeting-sweeper.js";
@@ -16,6 +17,7 @@ export default () => {
16
17
  // background loop is off.
17
18
  registerMeetingRemindersJob();
18
19
  registerBuilderMediaCompressionJob();
20
+ registerMediaVerificationJob();
19
21
  registerPollCalendarsJob();
20
22
  registerStaleMeetingSweeperJob();
21
23
  };
@@ -11,6 +11,7 @@ import {
11
11
  } from "h3";
12
12
  import { z } from "zod";
13
13
 
14
+ import finalizeRecording from "../../../../actions/finalize-recording.js";
14
15
  import { ensureRecordingSeekable } from "../../../../actions/lib/ensure-seekable-video.js";
15
16
  import requestTranscript from "../../../../actions/request-transcript.js";
16
17
  import { getDb, schema } from "../../../db/index.js";
@@ -22,7 +23,7 @@ import {
22
23
 
23
24
  const bodySchema = z.object({
24
25
  recordingId: z.string().min(1).max(200),
25
- kind: z.enum(["seekable", "transcript"]),
26
+ kind: z.enum(["media-ready", "seekable", "transcript"]),
26
27
  token: z.string().min(1),
27
28
  delayMs: z.number().int().min(0).max(30_000).optional(),
28
29
  retryAttempt: z.number().int().min(1).max(10).optional(),
@@ -61,7 +62,8 @@ export default defineEventHandler(async (event: H3Event) => {
61
62
  setResponseStatus(event, 404);
62
63
  return { ok: false, error: "Recording not found" };
63
64
  }
64
- if (recording.status !== "ready") {
65
+ const requiredStatus = kind === "media-ready" ? "processing" : "ready";
66
+ if (recording.status !== requiredStatus) {
65
67
  return {
66
68
  ok: true,
67
69
  recordingId,
@@ -84,6 +86,7 @@ export default defineEventHandler(async (event: H3Event) => {
84
86
  kind,
85
87
  retryAttempt,
86
88
  regenerate,
89
+ requireAccepted: kind === "media-ready",
87
90
  });
88
91
  return {
89
92
  ok: true,
@@ -100,6 +103,13 @@ export default defineEventHandler(async (event: H3Event) => {
100
103
  });
101
104
  return { ok: true, kind, result };
102
105
  }
106
+ if (kind === "media-ready") {
107
+ const result = await finalizeRecording.run({
108
+ id: recordingId,
109
+ mediaVerificationRetryAttempt: retryAttempt ?? 1,
110
+ });
111
+ return { ok: true, kind, result };
112
+ }
103
113
 
104
114
  const result = await requestTranscript.run({
105
115
  recordingId,
@@ -42,6 +42,8 @@ import {
42
42
  } from "../../../shared/transcript-segments.js";
43
43
  import { resolveTranscriptPresentation } from "../../../shared/transcript-status.js";
44
44
  import { getDb, schema } from "../../db/index.js";
45
+ import { isMediaVerificationPending } from "../../lib/media-verification-state.js";
46
+ import { resolvePlayerThumbnailUrl } from "../../lib/player-thumbnail-url.js";
45
47
  import { resolvePlayerVideoUrl } from "../../lib/player-video-url.js";
46
48
  import {
47
49
  getOrganizationRoleForEmail,
@@ -367,6 +369,10 @@ export default defineEventHandler(async (event) => {
367
369
  resolvedVideoUrl,
368
370
  protectedMediaToken,
369
371
  );
372
+ const playbackThumbnailUrl = resolvePlayerThumbnailUrl(rec, {
373
+ accessToken: protectedMediaToken,
374
+ appPath,
375
+ });
370
376
 
371
377
  const canExposeAgentContext =
372
378
  (rec.visibility === "public" || tokenAllowsAgentAccess || viewerIsOwner) &&
@@ -393,13 +399,18 @@ export default defineEventHandler(async (event) => {
393
399
  // Referer of any outbound link the share page renders.
394
400
  setResponseHeader(event, "Referrer-Policy", "no-referrer");
395
401
  const transcriptPresentation = resolveTranscriptPresentation(transcript);
402
+ const verificationPending = await isMediaVerificationPending({
403
+ ownerEmail: rec.ownerEmail,
404
+ recordingId,
405
+ recordingStatus: rec.status,
406
+ });
396
407
 
397
408
  return {
398
409
  recording: {
399
410
  id: rec.id,
400
411
  title: rec.title,
401
412
  description: rec.description,
402
- thumbnailUrl: rec.thumbnailUrl,
413
+ thumbnailUrl: playbackThumbnailUrl,
403
414
  animatedThumbnailUrl: rec.animatedThumbnailUrl,
404
415
  sourceAppName: rec.sourceAppName,
405
416
  durationMs: rec.durationMs,
@@ -411,6 +422,7 @@ export default defineEventHandler(async (event) => {
411
422
  hasAudio: Boolean(rec.hasAudio),
412
423
  hasCamera: Boolean(rec.hasCamera),
413
424
  status: rec.status,
425
+ verificationPending,
414
426
  uploadProgress: rec.uploadProgress,
415
427
  failureReason: rec.failureReason,
416
428
  // Don't leak the password to clients; just indicate whether one was set.