@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,340 @@
1
+ /**
2
+ * Serve a recording thumbnail from the same origin as the public player.
3
+ *
4
+ * Thumbnail providers may return expiring or hotlink-protected URLs. Public
5
+ * share pages already proxy video through `/api/video/:recordingId`; using the
6
+ * same contract here keeps embeds and crawler previews reliable.
7
+ */
8
+
9
+ import {
10
+ createSsrfSafeDispatcher,
11
+ isBlockedExtensionUrlWithDns,
12
+ } from "@agent-native/core/extensions/url-safety";
13
+ import { getOrgContext } from "@agent-native/core/org";
14
+ import {
15
+ captureRouteError,
16
+ getSession,
17
+ runWithRequestContext,
18
+ signShortLivedToken,
19
+ verifyShortLivedToken,
20
+ } from "@agent-native/core/server";
21
+ import { resolveAccess } from "@agent-native/core/sharing";
22
+ import { eq } from "drizzle-orm";
23
+ import {
24
+ defineEventHandler,
25
+ getCookie,
26
+ getQuery,
27
+ getRequestURL,
28
+ getRouterParam,
29
+ setCookie,
30
+ setResponseStatus,
31
+ type H3Event,
32
+ } from "h3";
33
+
34
+ import { getDb, schema } from "../../../db/index.js";
35
+ import { getOrganizationRoleForEmail } from "../../../lib/recordings.js";
36
+ import { verifySharePassword } from "../../../lib/share-password.js";
37
+
38
+ const FETCH_TIMEOUT_MS = 30_000;
39
+ const PROTECTED_MEDIA_ACCESS_TTL_SECONDS = 6 * 60 * 60;
40
+ const PROTECTED_MEDIA_COOKIE_PREFIX = "clips_media_";
41
+ const SAFE_RASTER_IMAGE_TYPES = new Set([
42
+ "image/avif",
43
+ "image/bmp",
44
+ "image/gif",
45
+ "image/jpeg",
46
+ "image/png",
47
+ "image/webp",
48
+ "image/x-icon",
49
+ ]);
50
+
51
+ type ThumbnailRecording = {
52
+ id: string;
53
+ thumbnailUrl?: string | null;
54
+ animatedThumbnailUrl?: string | null;
55
+ expiresAt?: string | null;
56
+ organizationId?: string | null;
57
+ password?: string | null;
58
+ visibility?: string | null;
59
+ };
60
+
61
+ function appPath(path: string): string {
62
+ if (!path.startsWith("/")) return path;
63
+ const raw = process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || "";
64
+ const base = raw.trim().replace(/^\/+/, "").replace(/\/+$/, "");
65
+ return base ? `/${base}${path}` : path;
66
+ }
67
+
68
+ function cookieName(recordingId: string): string {
69
+ return `${PROTECTED_MEDIA_COOKIE_PREFIX}${recordingId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
70
+ }
71
+
72
+ function cookiePath(recordingId: string): string {
73
+ return appPath(`/api/thumbnail/${encodeURIComponent(recordingId)}`);
74
+ }
75
+
76
+ function isHttpsRequest(event: H3Event): boolean {
77
+ const requestUrl = getRequestURL(event);
78
+ return (
79
+ requestUrl.protocol === "https:" ||
80
+ process.env.APP_URL?.startsWith("https://") === true
81
+ );
82
+ }
83
+
84
+ function renewProtectedMediaCookie(event: H3Event, recordingId: string): void {
85
+ const token = signShortLivedToken({
86
+ resourceId: recordingId,
87
+ ttlSeconds: PROTECTED_MEDIA_ACCESS_TTL_SECONDS,
88
+ });
89
+ const secure = isHttpsRequest(event);
90
+ setCookie(event, cookieName(recordingId), token, {
91
+ httpOnly: true,
92
+ sameSite: secure ? "none" : "lax",
93
+ secure,
94
+ ...(secure ? { partitioned: true } : {}),
95
+ path: cookiePath(recordingId),
96
+ maxAge: PROTECTED_MEDIA_ACCESS_TTL_SECONDS,
97
+ });
98
+ }
99
+
100
+ function isRecursiveThumbnailUrl(value: string, recordingId: string): boolean {
101
+ try {
102
+ const parsed = new URL(value, "http://local.test");
103
+ const expected = `/api/thumbnail/${encodeURIComponent(recordingId)}`;
104
+ return parsed.pathname === expected || parsed.pathname.endsWith(expected);
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function dataUrlResponse(sourceUrl: string): Response | null {
111
+ const match = sourceUrl.match(/^data:(image\/[\w.+-]+)(;base64)?,(.*)$/s);
112
+ if (!match) return null;
113
+
114
+ const [, rawMimeType, encoding, payload] = match;
115
+ const mimeType = rawMimeType.toLowerCase();
116
+ if (!SAFE_RASTER_IMAGE_TYPES.has(mimeType)) return null;
117
+ try {
118
+ const bytes = encoding
119
+ ? decodeBase64(payload)
120
+ : new TextEncoder().encode(decodeURIComponent(payload));
121
+ return imageResponse(bytes, mimeType);
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
128
+ const binary = globalThis.atob(value);
129
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
130
+ for (let index = 0; index < binary.length; index++) {
131
+ bytes[index] = binary.charCodeAt(index);
132
+ }
133
+ return bytes;
134
+ }
135
+
136
+ function imageResponse(
137
+ body: BodyInit | null,
138
+ mimeType: string,
139
+ status = 200,
140
+ ): Response {
141
+ const headers = new Headers({
142
+ "Content-Type": mimeType,
143
+ "Cache-Control": "private, max-age=0, no-store",
144
+ "Referrer-Policy": "no-referrer",
145
+ "X-Content-Type-Options": "nosniff",
146
+ });
147
+ return new Response(body, { status, headers });
148
+ }
149
+
150
+ async function fetchThumbnail(sourceUrl: string): Promise<Response> {
151
+ let currentUrl = sourceUrl;
152
+ const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
153
+
154
+ for (let redirects = 0; redirects <= 4; redirects++) {
155
+ if (await isBlockedExtensionUrlWithDns(currentUrl)) {
156
+ return imageResponse(null, "text/plain", 403);
157
+ }
158
+
159
+ const controller = new AbortController();
160
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
161
+ let upstream: Response;
162
+ try {
163
+ const fetchOptions: RequestInit & { dispatcher?: unknown } = {
164
+ redirect: "manual",
165
+ signal: controller.signal,
166
+ };
167
+ if (dispatcher) fetchOptions.dispatcher = dispatcher;
168
+ upstream = await fetch(currentUrl, fetchOptions);
169
+ } finally {
170
+ clearTimeout(timeout);
171
+ }
172
+
173
+ if (upstream.status < 300 || upstream.status >= 400) {
174
+ const contentType =
175
+ upstream.headers
176
+ .get("content-type")
177
+ ?.split(";", 1)[0]
178
+ ?.trim()
179
+ .toLowerCase() ?? "";
180
+ if (!SAFE_RASTER_IMAGE_TYPES.has(contentType)) {
181
+ await upstream.body?.cancel().catch(() => {});
182
+ return imageResponse(
183
+ null,
184
+ "text/plain; charset=utf-8",
185
+ upstream.ok ? 415 : upstream.status,
186
+ );
187
+ }
188
+ return imageResponse(upstream.body, contentType, upstream.status);
189
+ }
190
+
191
+ const location = upstream.headers.get("location");
192
+ await upstream.body?.cancel().catch(() => {});
193
+ if (!location) return imageResponse(null, "text/plain", upstream.status);
194
+ currentUrl = new URL(location, currentUrl).href;
195
+ }
196
+
197
+ return imageResponse(null, "text/plain", 508);
198
+ }
199
+
200
+ async function loadRecording(recordingId: string, event: H3Event) {
201
+ const access = await resolveAccess("recording", recordingId);
202
+ let recording = (access?.resource as ThumbnailRecording | undefined) ?? null;
203
+ const role = access?.role ?? null;
204
+ const session = await getSession(event).catch(() => null);
205
+
206
+ if (!recording) {
207
+ const [row] = await getDb()
208
+ .select({
209
+ id: schema.recordings.id,
210
+ thumbnailUrl: schema.recordings.thumbnailUrl,
211
+ animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl,
212
+ expiresAt: schema.recordings.expiresAt,
213
+ organizationId: schema.recordings.organizationId,
214
+ password: schema.recordings.password,
215
+ visibility: schema.recordings.visibility,
216
+ })
217
+ .from(schema.recordings)
218
+ .where(eq(schema.recordings.id, recordingId))
219
+ .limit(1);
220
+
221
+ if (!row) return { error: "Not found", status: 404 } as const;
222
+
223
+ let viewerIsOrgMember = false;
224
+ if (session?.email && row.visibility === "org" && row.organizationId) {
225
+ const orgRole = await getOrganizationRoleForEmail(
226
+ row.organizationId,
227
+ session.email,
228
+ ).catch(() => null);
229
+ viewerIsOrgMember = Boolean(orgRole);
230
+ }
231
+ if (row.visibility !== "public" && !viewerIsOrgMember) {
232
+ return { error: "Not found", status: 404 } as const;
233
+ }
234
+
235
+ recording = row;
236
+ }
237
+
238
+ return { recording, role } as const;
239
+ }
240
+
241
+ export default defineEventHandler(async (event: H3Event) => {
242
+ const recordingId = getRouterParam(event, "recordingId");
243
+ if (!recordingId) {
244
+ setResponseStatus(event, 400);
245
+ return { error: "Missing recordingId" };
246
+ }
247
+
248
+ const session = await getSession(event).catch(() => null);
249
+ const orgCtx = await getOrgContext(event).catch(() => null);
250
+
251
+ return runWithRequestContext(
252
+ { userEmail: session?.email, orgId: orgCtx?.orgId ?? session?.orgId },
253
+ async () => {
254
+ const loaded = await loadRecording(recordingId, event);
255
+ if ("error" in loaded) {
256
+ setResponseStatus(event, loaded.status);
257
+ return { error: loaded.error };
258
+ }
259
+
260
+ const { recording } = loaded;
261
+ if (recording.expiresAt) {
262
+ const expires = new Date(recording.expiresAt).getTime();
263
+ if (Number.isFinite(expires) && expires < Date.now()) {
264
+ setResponseStatus(event, 410);
265
+ return { error: "Recording has expired" };
266
+ }
267
+ }
268
+
269
+ const query = getQuery(event) as {
270
+ password?: unknown;
271
+ t?: unknown;
272
+ };
273
+ if (recording.password && loaded.role !== "owner") {
274
+ const queryToken = typeof query.t === "string" ? query.t : "";
275
+ const cookieToken = getCookie(event, cookieName(recordingId)) ?? "";
276
+ const password =
277
+ typeof query.password === "string" ? query.password : "";
278
+ const allowed =
279
+ (queryToken && verifyShortLivedToken(queryToken, recordingId).ok) ||
280
+ (cookieToken && verifyShortLivedToken(cookieToken, recordingId).ok) ||
281
+ (password && verifySharePassword(password, recording.password));
282
+ if (!allowed) {
283
+ setResponseStatus(event, 401);
284
+ return { error: "Password required", passwordRequired: true };
285
+ }
286
+ renewProtectedMediaCookie(event, recordingId);
287
+ }
288
+
289
+ const sourceUrl =
290
+ recording.thumbnailUrl || recording.animatedThumbnailUrl;
291
+ if (!sourceUrl) {
292
+ setResponseStatus(event, 404);
293
+ return { error: "Thumbnail not found" };
294
+ }
295
+
296
+ if (sourceUrl.startsWith("data:")) {
297
+ return (
298
+ dataUrlResponse(sourceUrl) ??
299
+ imageResponse(null, "text/plain; charset=utf-8", 415)
300
+ );
301
+ }
302
+ if (isRecursiveThumbnailUrl(sourceUrl, recordingId)) {
303
+ setResponseStatus(event, 404);
304
+ return { error: "Thumbnail not found" };
305
+ }
306
+
307
+ let resolvedSourceUrl = sourceUrl;
308
+ if (sourceUrl.startsWith("/")) {
309
+ resolvedSourceUrl = new URL(sourceUrl, getRequestURL(event).origin)
310
+ .href;
311
+ }
312
+
313
+ try {
314
+ const response = await fetchThumbnail(resolvedSourceUrl);
315
+ if (response.status >= 500) {
316
+ captureRouteError(
317
+ new Error(
318
+ `Storage provider returned ${response.status} for thumbnail`,
319
+ ),
320
+ {
321
+ route: "api/thumbnail",
322
+ tags: { upstreamStatus: String(response.status) },
323
+ extra: { recordingId },
324
+ },
325
+ );
326
+ setResponseStatus(event, 502);
327
+ return { error: "The recording thumbnail could not be loaded." };
328
+ }
329
+ return response;
330
+ } catch (error) {
331
+ captureRouteError(error, {
332
+ route: "api/thumbnail",
333
+ extra: { recordingId },
334
+ });
335
+ setResponseStatus(event, 502);
336
+ return { error: "The recording thumbnail could not be loaded." };
337
+ }
338
+ },
339
+ );
340
+ });
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ deleteAppState,
9
10
  readAppState,
10
11
  writeAppState,
11
12
  deleteAppStateByPrefix,
@@ -22,6 +23,7 @@ import {
22
23
  } from "h3";
23
24
 
24
25
  import { getDb, schema } from "../../../../db/index.js";
26
+ import { mediaVerificationStateKey } from "../../../../lib/media-verification-state.js";
25
27
  import {
26
28
  getEventOwnerContext,
27
29
  ownerEmailMatches,
@@ -75,6 +77,25 @@ export default defineEventHandler(async (event: H3Event) => {
75
77
  return { ok: true, recordingId, alreadyReady: true, chunksCleared: 0 };
76
78
  }
77
79
 
80
+ const existingUploadStateRaw = await readAppState(
81
+ `recording-upload-${recordingId}`,
82
+ ).catch(() => null);
83
+ const existingUploadState =
84
+ existingUploadStateRaw && typeof existingUploadStateRaw === "object"
85
+ ? (existingUploadStateRaw as Record<string, unknown>)
86
+ : {};
87
+ if (
88
+ existing.status === "processing" &&
89
+ existingUploadState.pendingMediaVerification === true
90
+ ) {
91
+ return {
92
+ ok: true,
93
+ recordingId,
94
+ verificationPending: true,
95
+ chunksCleared: 0,
96
+ };
97
+ }
98
+
78
99
  const preserveRecoveryState =
79
100
  isStoredButUnservableFinalizeError(failureReason) ||
80
101
  isStoredButUnservableFinalizeError(existing.failureReason);
@@ -104,13 +125,6 @@ export default defineEventHandler(async (event: H3Event) => {
104
125
  })
105
126
  .where(eq(schema.recordings.id, recordingId));
106
127
 
107
- const existingUploadStateRaw = await readAppState(
108
- `recording-upload-${recordingId}`,
109
- ).catch(() => null);
110
- const existingUploadState =
111
- existingUploadStateRaw && typeof existingUploadStateRaw === "object"
112
- ? (existingUploadStateRaw as Record<string, unknown>)
113
- : {};
114
128
  await writeAppState(`recording-upload-${recordingId}`, {
115
129
  ...existingUploadState,
116
130
  recordingId,
@@ -118,6 +132,9 @@ export default defineEventHandler(async (event: H3Event) => {
118
132
  failureReason,
119
133
  updatedAt: now,
120
134
  });
135
+ await deleteAppState(mediaVerificationStateKey(recordingId)).catch(
136
+ () => {},
137
+ );
121
138
 
122
139
  const cleared = preserveRecoveryState
123
140
  ? 0
@@ -101,6 +101,37 @@ function stateNumber(
101
101
  return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
102
102
  }
103
103
 
104
+ function pendingMediaVerificationState(
105
+ value: unknown,
106
+ ): Record<string, unknown> | null {
107
+ if (!value || typeof value !== "object") return null;
108
+ const state = value as Record<string, unknown>;
109
+ return state.status === "processing" &&
110
+ state.pendingMediaVerification === true
111
+ ? state
112
+ : null;
113
+ }
114
+
115
+ function acceptedProcessingResponse(
116
+ event: H3Event,
117
+ recordingId: string,
118
+ state: Record<string, unknown>,
119
+ ) {
120
+ setResponseStatus(event, 202);
121
+ return {
122
+ ok: true,
123
+ finalized: false,
124
+ verificationPending: true,
125
+ status: "processing" as const,
126
+ retryAfterMs: 3_000,
127
+ id: recordingId,
128
+ videoUrl: typeof state.videoUrl === "string" ? state.videoUrl : undefined,
129
+ videoSizeBytes: stateNumber(state, "videoSizeBytes"),
130
+ sourceSizeBytes: stateNumber(state, "sourceSizeBytes"),
131
+ durationMs: stateNumber(state, "durationMs"),
132
+ };
133
+ }
134
+
104
135
  function expectedDataChunksForFinalPost(
105
136
  index: number,
106
137
  bodySize: number,
@@ -216,6 +247,15 @@ export default defineEventHandler(async (event: H3Event) => {
216
247
  throw createError({ statusCode: 404, message: "Recording not found" });
217
248
  }
218
249
 
250
+ if (isFinal && existing.status === "processing") {
251
+ const pendingState = pendingMediaVerificationState(
252
+ await readAppState(`recording-upload-${recordingId}`).catch(() => null),
253
+ );
254
+ if (pendingState) {
255
+ return acceptedProcessingResponse(event, recordingId, pendingState);
256
+ }
257
+ }
258
+
219
259
  // Resumable streaming path — forward chunks directly to the provider.
220
260
  const resumableSession = await getResumableSession(recordingId);
221
261
  if (resumableSession && isStreamingUploadDisabled()) {
@@ -567,13 +607,17 @@ export default defineEventHandler(async (event: H3Event) => {
567
607
  const waitingForStorage =
568
608
  (result as any)?.status === "waiting_storage" ||
569
609
  (result as any)?.storageSetupRequired === true;
570
- if (waitingForStorage) {
610
+ const verificationPending =
611
+ (result as any)?.status === "processing" &&
612
+ (result as any)?.verificationPending === true;
613
+ if (waitingForStorage || verificationPending) {
571
614
  setResponseStatus(event, 202);
572
615
  }
573
616
  return {
574
617
  ok: true,
575
- finalized: !waitingForStorage,
618
+ finalized: !waitingForStorage && !verificationPending,
576
619
  waitingForStorage,
620
+ ...(verificationPending ? { retryAfterMs: 3_000 } : {}),
577
621
  ...result,
578
622
  };
579
623
  } catch (err) {
@@ -650,6 +694,16 @@ export default defineEventHandler(async (event: H3Event) => {
650
694
  hasCamera: committed.hasCamera,
651
695
  };
652
696
  }
697
+ if (committed?.status === "processing" && committed.videoUrl) {
698
+ const pendingState = pendingMediaVerificationState(
699
+ await readAppState(`recording-upload-${recordingId}`).catch(
700
+ () => null,
701
+ ),
702
+ );
703
+ if (pendingState) {
704
+ return acceptedProcessingResponse(event, recordingId, pendingState);
705
+ }
706
+ }
653
707
  trackUploadBlockingFailure(ownerEmail, {
654
708
  stage: "finalize_recording",
655
709
  failureKind: "finalize_error",
@@ -657,7 +711,7 @@ export default defineEventHandler(async (event: H3Event) => {
657
711
  uploadMode: "buffered",
658
712
  errorMessage: err instanceof Error ? err.message : String(err),
659
713
  });
660
- await db
714
+ const failed = await db
661
715
  .update(schema.recordings)
662
716
  .set({
663
717
  status: "failed",
@@ -665,7 +719,17 @@ export default defineEventHandler(async (event: H3Event) => {
665
719
  err instanceof Error ? err.message : "Finalize failed",
666
720
  updatedAt: new Date().toISOString(),
667
721
  })
668
- .where(eq(schema.recordings.id, recordingId));
722
+ .where(
723
+ and(
724
+ eq(schema.recordings.id, recordingId),
725
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
726
+ eq(schema.recordings.status, "processing"),
727
+ ),
728
+ )
729
+ .returning({ id: schema.recordings.id });
730
+ if (failed.length !== 1) {
731
+ throw err;
732
+ }
669
733
  const failedUploadStateRaw = await readAppState(
670
734
  `recording-upload-${recordingId}`,
671
735
  ).catch(() => null);
@@ -862,7 +926,16 @@ async function handleResumableChunk(
862
926
  error: "Recording was cancelled before it finished saving.",
863
927
  };
864
928
  }
865
- return { ok: true, finalized: true, ...result };
929
+ const verificationPending =
930
+ (result as any)?.status === "processing" &&
931
+ (result as any)?.verificationPending === true;
932
+ if (verificationPending) setResponseStatus(event, 202);
933
+ return {
934
+ ok: true,
935
+ finalized: !verificationPending,
936
+ ...(verificationPending ? { retryAfterMs: 3_000 } : {}),
937
+ ...result,
938
+ };
866
939
  } catch (err) {
867
940
  console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
868
941
  const db = getDb();
@@ -932,6 +1005,14 @@ async function handleResumableChunk(
932
1005
  hasCamera: committed.hasCamera,
933
1006
  };
934
1007
  }
1008
+ if (committed?.status === "processing" && committed.videoUrl) {
1009
+ const pendingState = pendingMediaVerificationState(
1010
+ await readAppState(`recording-upload-${recordingId}`).catch(() => null),
1011
+ );
1012
+ if (pendingState) {
1013
+ return acceptedProcessingResponse(event, recordingId, pendingState);
1014
+ }
1015
+ }
935
1016
 
936
1017
  trackUploadBlockingFailure(ownerEmail, {
937
1018
  stage: "finalize_recording",
@@ -943,7 +1024,7 @@ async function handleResumableChunk(
943
1024
  const failureReason =
944
1025
  err instanceof Error ? err.message : "Finalize failed";
945
1026
  const failedAt = new Date().toISOString();
946
- await db
1027
+ const failed = await db
947
1028
  .update(schema.recordings)
948
1029
  .set({
949
1030
  status: "failed",
@@ -954,8 +1035,11 @@ async function handleResumableChunk(
954
1035
  and(
955
1036
  eq(schema.recordings.id, recordingId),
956
1037
  ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
1038
+ eq(schema.recordings.status, "processing"),
957
1039
  ),
958
- );
1040
+ )
1041
+ .returning({ id: schema.recordings.id });
1042
+ if (failed.length !== 1) throw err;
959
1043
  const failedUploadStateRaw = await readAppState(
960
1044
  `recording-upload-${recordingId}`,
961
1045
  ).catch(() => null);
@@ -42,6 +42,7 @@ import {
42
42
  } from "h3";
43
43
 
44
44
  import { getDb, schema } from "../../../../db/index.js";
45
+ import { isMediaVerificationPending } from "../../../../lib/media-verification-state.js";
45
46
  import {
46
47
  getEventOwnerContext,
47
48
  ownerEmailMatches,
@@ -115,7 +116,11 @@ export default defineEventHandler(async (event: H3Event) => {
115
116
  const db = getDb();
116
117
 
117
118
  const [existing] = await db
118
- .select({ id: schema.recordings.id })
119
+ .select({
120
+ id: schema.recordings.id,
121
+ status: schema.recordings.status,
122
+ videoUrl: schema.recordings.videoUrl,
123
+ })
119
124
  .from(schema.recordings)
120
125
  .where(
121
126
  and(
@@ -129,6 +134,22 @@ export default defineEventHandler(async (event: H3Event) => {
129
134
  return { error: "Recording not found" };
130
135
  }
131
136
 
137
+ if (existing.status === "ready" && existing.videoUrl) {
138
+ setResponseStatus(event, 409);
139
+ return { error: "Recording is already ready" };
140
+ }
141
+
142
+ if (
143
+ await isMediaVerificationPending({
144
+ ownerEmail,
145
+ recordingId,
146
+ recordingStatus: existing.status,
147
+ })
148
+ ) {
149
+ setResponseStatus(event, 409);
150
+ return { error: "Recording is still being verified" };
151
+ }
152
+
132
153
  const cleared = await deleteAppStateByPrefix(
133
154
  `recording-chunks-${recordingId}-`,
134
155
  );
@@ -1,9 +1,11 @@
1
+ import { readAppState } from "@agent-native/core/application-state";
1
2
  import { runWithRequestContext } from "@agent-native/core/server";
2
3
  import { and, eq } from "drizzle-orm";
3
4
  import {
4
5
  createError,
5
6
  defineEventHandler,
6
7
  getRouterParam,
8
+ setResponseHeader,
7
9
  setResponseStatus,
8
10
  type H3Event,
9
11
  } from "h3";
@@ -23,6 +25,7 @@ function appPath(path: string): string {
23
25
  }
24
26
 
25
27
  export default defineEventHandler(async (event: H3Event) => {
28
+ setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
26
29
  const recordingId = getRouterParam(event, "recordingId");
27
30
  if (!recordingId) {
28
31
  throw createError({ statusCode: 400, message: "Missing recordingId" });
@@ -55,10 +58,21 @@ export default defineEventHandler(async (event: H3Event) => {
55
58
  return { error: "Not found" };
56
59
  }
57
60
 
61
+ const uploadState = await readAppState(
62
+ `recording-upload-${recordingId}`,
63
+ ).catch(() => null);
64
+ const verificationPending = Boolean(
65
+ uploadState &&
66
+ typeof uploadState === "object" &&
67
+ (uploadState as Record<string, unknown>).pendingMediaVerification ===
68
+ true,
69
+ );
70
+
58
71
  return {
59
72
  recording: {
60
73
  id: recording.id,
61
74
  status: recording.status,
75
+ verificationPending,
62
76
  videoUrl: resolvePlayerVideoUrl(recording, {
63
77
  appPath,
64
78
  proxyRemoteMedia: true,