@agent-native/core-corpus 0.211.0 → 0.211.1-nightly-20261020213022

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 (54) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  3. package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  4. package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  5. package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  6. package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  7. package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  8. package/corpus/templates/clips/actions/delete-recording-permanent.ts +97 -72
  9. package/corpus/templates/clips/app/components/player/transcript-import-button.tsx +9 -2
  10. package/corpus/templates/clips/changelog/2026-10-16-if-permanently-deleting-a-clip-fails-the-clip-and-its-video-.md +6 -0
  11. package/corpus/templates/clips/changelog/2026-10-16-the-replace-transcript-dialog-keeps-showing-the-file-name-wh.md +6 -0
  12. package/corpus/templates/clips/changelog/2026-10-16-tooltips-no-longer-pop-up-on-their-own-after-a-menu-popover-.md +6 -0
  13. package/corpus/templates/clips/changelog/2026-10-16-video-posters-load-again-when-your-own-storage-runs-on-a-pri.md +6 -0
  14. package/corpus/templates/clips/server/lib/upload-recording-media.ts +5 -2
  15. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +123 -24
  16. package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  17. package/corpus/templates/content/app/components/editor/CommentAiRecipient.tsx +29 -0
  18. package/corpus/templates/content/app/components/editor/CommentComposer.tsx +6 -8
  19. package/corpus/templates/content/changelog/2026-10-15-the-comment-menu-lists-each-ai-model-once-instead-of-once-pe.md +6 -0
  20. package/corpus/templates/content/scripts/stored-form-census-kinds.ts +931 -0
  21. package/corpus/templates/content/scripts/stored-form-census-scan.ts +814 -0
  22. package/corpus/templates/content/scripts/stored-form-census.ts +505 -0
  23. package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  24. package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  25. package/corpus/templates/design/.generated/bridge/native-builtin-metadata.generated.ts +301 -0
  26. package/corpus/templates/design/.generated/bridge/native-worker-source.generated.ts +6 -0
  27. package/corpus/templates/design/actions/update-file.ts +10 -15
  28. package/corpus/templates/design/changelog/2026-10-20-public-design-previews-no-longer-show-spurious-export-errors.md +6 -0
  29. package/corpus/templates/design/changelog/2026-10-20-screen-image-uploads-no-longer-overwrite-edits-made-during-t.md +6 -0
  30. package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  31. package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  32. package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  33. package/corpus/templates/mail/actions/list-inbox-threads.ts +221 -57
  34. package/corpus/templates/mail/actions/resync-inbox.ts +1 -0
  35. package/corpus/templates/mail/actions/sync-inbox.ts +9 -3
  36. package/corpus/templates/mail/app/components/email/EmailList.tsx +44 -10
  37. package/corpus/templates/mail/app/components/email/EmailListItem.tsx +53 -1
  38. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +5 -2
  39. package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +63 -3
  40. package/corpus/templates/mail/app/i18n/en-US.ts +3 -0
  41. package/corpus/templates/mail/app/lib/thread-cache.ts +3 -0
  42. package/corpus/templates/mail/app/pages/InboxPage.tsx +6 -0
  43. package/corpus/templates/mail/app/root.tsx +7 -2
  44. package/corpus/templates/mail/changelog/2026-10-20-mail-s-inbox-loads-faster-opens-threads-sooner-after-you-hov.md +6 -0
  45. package/corpus/templates/mail/server/db/schema.ts +3 -0
  46. package/corpus/templates/mail/server/lib/inbox-push-watch.ts +35 -0
  47. package/corpus/templates/mail/server/lib/inbox-store.ts +254 -1
  48. package/corpus/templates/mail/server/lib/inbox-sync.ts +66 -11
  49. package/corpus/templates/mail/server/lib/inbox-tab-windows.fake.ts +67 -0
  50. package/corpus/templates/mail/server/plugins/db.ts +5 -0
  51. package/corpus/templates/mail/shared/inbox-threads.ts +52 -0
  52. package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  53. package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  54. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -32,4 +32,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core-corpus/corpu
32
32
 
33
33
  ## Generated Counts
34
34
 
35
- - template files: 13210
35
+ - template files: 13273
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","analytics-alert-jobs","analytics-rollup-backfill-jobs","auth","collab","config","content-metrics-collection-jobs","core-routes","creative-context","dashboard-report-jobs","db","feature-flags","labs","onboarding","otel","session-replay-retention-jobs","transactional-emails","uptime-monitor-jobs"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","creative-context","db","labs","onboarding","otel"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","brain-jobs","core-routes","db","otel"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","core-routes","db","event-rules","oauth-refresh","otel","transactional-emails"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","otel"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","calendar-jobs","config","core-routes","db","feature-flags","labs","onboarding","onboarding-calendar","otel","transactional-email-catalog"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -31,6 +31,44 @@ import {
31
31
  } from "../server/lib/video-storage.js";
32
32
  import { trashRecordingContextFootage } from "./remove-recording-context.js";
33
33
 
34
+ /** The URLs among `urls` that a recording other than `recordingId` uses. */
35
+ async function mediaUsedElsewhere(
36
+ db: Pick<ReturnType<typeof getDb>, "select">,
37
+ recordingId: string,
38
+ urls: string[],
39
+ ): Promise<Set<string>> {
40
+ const used = new Set<string>();
41
+ if (urls.length === 0) return used;
42
+ // guard:allow-unscoped — a file another owner's recording uses must survive too; only matching URLs leave this function, never rows.
43
+ const references = await db
44
+ .select({
45
+ videoUrl: schema.recordings.videoUrl,
46
+ thumbnailUrl: schema.recordings.thumbnailUrl,
47
+ animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl,
48
+ imageUrl: schema.recordings.imageUrl,
49
+ baseImageUrl: schema.recordings.baseImageUrl,
50
+ })
51
+ .from(schema.recordings)
52
+ .where(
53
+ and(
54
+ ne(schema.recordings.id, recordingId),
55
+ or(
56
+ inArray(schema.recordings.videoUrl, urls),
57
+ inArray(schema.recordings.thumbnailUrl, urls),
58
+ inArray(schema.recordings.animatedThumbnailUrl, urls),
59
+ inArray(schema.recordings.imageUrl, urls),
60
+ inArray(schema.recordings.baseImageUrl, urls),
61
+ ),
62
+ ),
63
+ );
64
+ for (const reference of references) {
65
+ for (const url of recordingMediaUrls(reference)) {
66
+ if (urls.includes(url)) used.add(url);
67
+ }
68
+ }
69
+ return used;
70
+ }
71
+
34
72
  export default defineAction({
35
73
  description:
36
74
  "Permanently delete a recording and every related row (comments, reactions, viewers, events, transcript, tags, CTAs, shares, diagnostics, bug reports, screen history context). The context's footage is trashed. This cannot be undone.",
@@ -64,37 +102,6 @@ export default defineAction({
64
102
  throw new Error(`Hosted recording allowance not found: ${args.id}`);
65
103
  }
66
104
 
67
- const mediaUrls = recordingMediaUrls(existing);
68
- const protectedUrls = new Set<string>();
69
- if (mediaUrls.length > 0) {
70
- const mediaReferences = await db
71
- .select({
72
- videoUrl: schema.recordings.videoUrl,
73
- thumbnailUrl: schema.recordings.thumbnailUrl,
74
- animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl,
75
- imageUrl: schema.recordings.imageUrl,
76
- baseImageUrl: schema.recordings.baseImageUrl,
77
- })
78
- .from(schema.recordings)
79
- .where(
80
- and(
81
- ne(schema.recordings.id, args.id),
82
- or(
83
- inArray(schema.recordings.videoUrl, mediaUrls),
84
- inArray(schema.recordings.thumbnailUrl, mediaUrls),
85
- inArray(schema.recordings.animatedThumbnailUrl, mediaUrls),
86
- inArray(schema.recordings.imageUrl, mediaUrls),
87
- inArray(schema.recordings.baseImageUrl, mediaUrls),
88
- ),
89
- ),
90
- );
91
- for (const reference of mediaReferences) {
92
- for (const url of recordingMediaUrls(reference)) {
93
- if (mediaUrls.includes(url)) protectedUrls.add(url);
94
- }
95
- }
96
- }
97
-
98
105
  // Some of a screenshot's files are unredacted: its leftovers, and its
99
106
  // base while boxes are still pending. The row is the only record of them,
100
107
  // so they go first, and the row stays — with its hold and a way to retry
@@ -139,8 +146,9 @@ export default defineAction({
139
146
  ? [existing.baseImageUrl]
140
147
  : []),
141
148
  ];
149
+ const usedElsewhere = await mediaUsedElsewhere(db, args.id, unredacted);
142
150
  for (const url of new Set(unredacted)) {
143
- if (protectedUrls.has(url)) continue;
151
+ if (usedElsewhere.has(url)) continue;
144
152
  let gone = false;
145
153
  try {
146
154
  gone = await deleteStoredMediaUrl(url, {
@@ -237,36 +245,27 @@ export default defineAction({
237
245
  .from(schema.recordingBrowserDiagnostics)
238
246
  .where(eq(schema.recordingBrowserDiagnostics.recordingId, args.id));
239
247
 
240
- // Hosted media goes after every other fallible step and just before the
241
- // row: once the row is gone nothing can find the objects or the allowance,
242
- // so an unconfirmed delete would strand both, while an earlier delete
243
- // would leave a surviving row pointing at deleted objects. Deleting an
244
- // already-deleted object succeeds, so a retry finishes it.
245
- const cleanupProtectedUrls = new Set([...protectedUrls, ...alreadyDeleted]);
246
- let hostedMediaCleanup: Awaited<
247
- ReturnType<typeof deleteRecordingMediaObjects>
248
- > | null = null;
249
- if (hostedAllowance) {
250
- hostedMediaCleanup = await deleteRecordingMediaObjects(existing, {
251
- protectedUrls: cleanupProtectedUrls,
252
- });
253
- const unprotectedUrls = mediaUrls.filter(
254
- (url) => !cleanupProtectedUrls.has(url),
255
- );
256
- if (
257
- hostedMediaCleanup.deleted + hostedMediaCleanup.external <
258
- unprotectedUrls.length
259
- ) {
260
- fail(
261
- "Hosted media for this recording could not be deleted from storage, so the recording was kept. Try again later.",
262
- { errorCode: "hosted_media_cleanup_incomplete", statusCode: 502 },
263
- );
248
+ const { deleted, protectedUrls } = await db.transaction(async (tx) => {
249
+ // The recording is locked before anything that hangs off it, the order
250
+ // apply-rewind-extension takes, so the two cannot deadlock. Its media is
251
+ // read under the lock: a write that landed since the read above would
252
+ // otherwise leave its new files out of the cleanup below.
253
+ const [locked] = await tx
254
+ .select()
255
+ .from(schema.recordings)
256
+ .where(eq(schema.recordings.id, args.id))
257
+ .for("update");
258
+ if (!locked) {
259
+ fail(`Recording not found: ${args.id}`, {
260
+ errorCode: "recording_not_found",
261
+ statusCode: 404,
262
+ });
264
263
  }
265
- }
266
-
267
- await db.transaction(async (tx) => {
268
- // Cascade delete every related row before deleting remote objects. If any
269
- // DB delete fails, the transaction rolls back and provider media stays put.
264
+ const protectedUrls = await mediaUsedElsewhere(
265
+ tx,
266
+ args.id,
267
+ recordingMediaUrls(locked),
268
+ );
270
269
  await tx
271
270
  .delete(schema.recordingComments)
272
271
  .where(eq(schema.recordingComments.recordingId, args.id));
@@ -312,16 +311,47 @@ export default defineAction({
312
311
  await tx
313
312
  .delete(schema.recordings)
314
313
  .where(eq(schema.recordings.id, args.id));
314
+ return { deleted: locked, protectedUrls };
315
315
  });
316
316
 
317
- const mediaCleanup =
318
- hostedMediaCleanup ??
319
- (await deleteRecordingMediaObjects(existing, {
320
- protectedUrls: cleanupProtectedUrls,
321
- }));
317
+ // Media goes only once its rows are gone, so a failed delete above leaves
318
+ // the Clip whole. A file that fails here stays in storage and nothing
319
+ // sweeps it later. A finished upload's bytes then stay counted, because
320
+ // each delete subtracts only the bytes it removed, and the log names the
321
+ // recording.
322
+ const cleanupProtectedUrls = new Set([...protectedUrls, ...alreadyDeleted]);
323
+ const mediaCleanup = await deleteRecordingMediaObjects(deleted, {
324
+ protectedUrls: cleanupProtectedUrls,
325
+ });
326
+ const hostedFilesLeft = hostedAllowance
327
+ ? recordingMediaUrls(deleted).filter(
328
+ (url) => !cleanupProtectedUrls.has(url),
329
+ ).length -
330
+ mediaCleanup.deleted -
331
+ mediaCleanup.external
332
+ : 0;
333
+ if (hostedFilesLeft > 0) {
334
+ console.error(
335
+ `[delete-recording-permanent] deleted ${args.id}, but ${hostedFilesLeft} of its hosted files could not be removed from storage.`,
336
+ mediaCleanup.errors,
337
+ );
338
+ }
322
339
 
323
340
  if (hostedAllowance) {
324
- if (hostedAllowance.status === "consumed") {
341
+ // Read again now the row is gone: an upload that finished while this
342
+ // ran has consumed the reservation read above.
343
+ const allowance = await getHostedClipRecordingAllowance({
344
+ recordingId: args.id,
345
+ userId: hostedAllowance.userId,
346
+ });
347
+ if (allowance?.status === "reserved") {
348
+ // Released even with files left: with the row gone nothing else can
349
+ // reach it, and it would hold one of the owner's hosted Clips for good.
350
+ await releaseHostedClipRecording({
351
+ recordingId: args.id,
352
+ userId: hostedAllowance.userId,
353
+ });
354
+ } else if (allowance?.status === "consumed" && hostedFilesLeft === 0) {
325
355
  const adjustment = await adjustHostedClipRecordingBytes({
326
356
  recordingId: args.id,
327
357
  userId: hostedAllowance.userId,
@@ -333,11 +363,6 @@ export default defineAction({
333
363
  `Hosted recording retained-byte quota could not be released: ${args.id}`,
334
364
  );
335
365
  }
336
- } else if (hostedAllowance.status === "reserved") {
337
- await releaseHostedClipRecording({
338
- recordingId: args.id,
339
- userId: hostedAllowance.userId,
340
- });
341
366
  }
342
367
  }
343
368
  const appActivityCleanup = diagnostics?.appActivityBlobJson
@@ -7,7 +7,7 @@ import {
7
7
  type TranscriptImportError,
8
8
  } from "@shared/transcript-import";
9
9
  import { IconFileUpload, IconLoader2 } from "@tabler/icons-react";
10
- import { useRef } from "react";
10
+ import { useRef, useState } from "react";
11
11
  import { toast } from "sonner";
12
12
 
13
13
  import {
@@ -70,6 +70,13 @@ export function TranscriptImportButton({
70
70
  const inputRef = useRef<HTMLInputElement>(null);
71
71
  const triggerRef = useRef<HTMLButtonElement>(null);
72
72
  const label = t("transcriptImport.addFile");
73
+ // The caller clears `replacement` as soon as the question is answered, a
74
+ // few frames before the dialog finishes closing, so the dialog keeps the
75
+ // last file it asked about.
76
+ const [shownReplacement, setShownReplacement] = useState(replacement);
77
+ if (replacement && replacement !== shownReplacement) {
78
+ setShownReplacement(replacement);
79
+ }
73
80
 
74
81
  // aria-disabled, not disabled: a disabled button cannot take the focus the
75
82
  // replace dialog hands back while the import is still saving.
@@ -210,7 +217,7 @@ export function TranscriptImportButton({
210
217
  </AlertDialogTitle>
211
218
  <AlertDialogDescription>
212
219
  {t("transcriptImport.replaceDescription", {
213
- fileName: replacement?.fileName ?? "",
220
+ fileName: shownReplacement?.fileName ?? "",
214
221
  })}
215
222
  </AlertDialogDescription>
216
223
  </AlertDialogHeader>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-16
4
+ ---
5
+
6
+ If permanently deleting a Clip fails, the Clip and its video are left intact.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-16
4
+ ---
5
+
6
+ The Replace transcript dialog keeps showing the file name while it closes.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-16
4
+ ---
5
+
6
+ Tooltips no longer pop up on their own after a menu, popover, or dialog closes.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-10-16
4
+ ---
5
+
6
+ Video posters load again when your own storage runs on a private network address.
@@ -108,6 +108,9 @@ export async function uploadRecordingMedia(
108
108
  }
109
109
 
110
110
  const { recordingId, ownerEmail, ...upload } = input;
111
+ // Stored under the recording's id, the only names the recording-bound bucket
112
+ // readers accept, so its own-bucket files load even from a private address.
113
+ const filename = recordingScopedFilename(recordingId, upload.filename);
111
114
  if (recording.storageMode === "hosted") {
112
115
  const uploadedBytes = upload.data.byteLength;
113
116
  if (!Number.isSafeInteger(uploadedBytes) || uploadedBytes < 0) {
@@ -122,7 +125,7 @@ export async function uploadRecordingMedia(
122
125
 
123
126
  const uploaded = await clipsHostedFileUploadProvider.upload({
124
127
  ...upload,
125
- filename: recordingScopedFilename(recordingId, upload.filename),
128
+ filename,
126
129
  ownerEmail,
127
130
  });
128
131
 
@@ -157,5 +160,5 @@ export async function uploadRecordingMedia(
157
160
  return uploaded;
158
161
  }
159
162
 
160
- return uploadFile({ ...upload, ownerEmail });
163
+ return uploadFile({ ...upload, filename, ownerEmail });
161
164
  }
@@ -66,6 +66,7 @@ type ThumbnailRecording = {
66
66
  baseImageUrl?: string | null;
67
67
  expiresAt?: string | null;
68
68
  organizationId?: string | null;
69
+ ownerEmail?: string | null;
69
70
  password?: string | null;
70
71
  sharePasswordVersion?: string | null;
71
72
  trashedAt?: string | null;
@@ -184,17 +185,48 @@ function defaultSocialImageResponse(): Response {
184
185
  });
185
186
  }
186
187
 
187
- async function fetchThumbnail(sourceUrl: string): Promise<Response> {
188
+ /** `work`'s result, or null if `deadline` passes before it starts or settles. */
189
+ async function settleBy<T>(
190
+ work: () => Promise<T>,
191
+ deadline: number,
192
+ ): Promise<T | null> {
193
+ if (Date.now() >= deadline) return null;
194
+ let timer: ReturnType<typeof setTimeout> | undefined;
195
+ try {
196
+ const result = await Promise.race([
197
+ work(),
198
+ new Promise<null>((resolve) => {
199
+ timer = setTimeout(() => resolve(null), deadline - Date.now());
200
+ }),
201
+ ]);
202
+ // The timer can fire late, so the clock, not the race, says what was late.
203
+ return Date.now() < deadline ? result : null;
204
+ } finally {
205
+ clearTimeout(timer);
206
+ }
207
+ }
208
+
209
+ async function fetchThumbnail(
210
+ sourceUrl: string,
211
+ deadline = Date.now() + FETCH_TIMEOUT_MS,
212
+ ): Promise<Response> {
188
213
  let currentUrl = sourceUrl;
189
214
  const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
190
215
 
191
216
  for (let redirects = 0; redirects <= 4; redirects++) {
192
- if (await isBlockedExtensionUrlWithDns(currentUrl)) {
193
- return imageResponse(null, "text/plain", 403);
217
+ // A DNS lookup cannot be cancelled, so the check gets only the budget
218
+ // left; a host it could not clear in time is refused, not fetched.
219
+ const blocked = await settleBy(
220
+ () => isBlockedExtensionUrlWithDns(currentUrl),
221
+ deadline,
222
+ );
223
+ if (blocked === null) {
224
+ throw new Error("Thumbnail fetch ran out of time before the host check");
194
225
  }
226
+ if (blocked) return imageResponse(null, "text/plain", 403);
195
227
 
196
228
  const controller = new AbortController();
197
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
229
+ const timeout = setTimeout(() => controller.abort(), deadline - Date.now());
198
230
  let upstream: Response;
199
231
  try {
200
232
  const fetchOptions: RequestInit & { dispatcher?: unknown } = {
@@ -244,7 +276,66 @@ async function fetchHostedThumbnail(
244
276
  timeoutMs: FETCH_TIMEOUT_MS,
245
277
  });
246
278
  if (!upstream) return imageResponse(null, "text/plain", 404);
279
+ return bucketImageResponse(upstream);
280
+ }
247
281
 
282
+ /**
283
+ * Reads a thumbnail from the owner's own bucket with the bucket's credentials,
284
+ * so it loads even when the bucket sits on a private address the public fetch
285
+ * refuses. The credentials are the owner's workspace secrets, so they resolve
286
+ * under the owner, not the viewer. Null means the bucket did not serve it.
287
+ */
288
+ async function fetchOwnBucketThumbnail(
289
+ sourceUrl: string,
290
+ recordingId: string,
291
+ recording: ThumbnailRecording,
292
+ ): Promise<Response | null> {
293
+ let upstream: Response | null;
294
+ try {
295
+ upstream = await runWithRequestContext(
296
+ {
297
+ userEmail: recording.ownerEmail ?? undefined,
298
+ orgId: recording.organizationId ?? undefined,
299
+ },
300
+ () =>
301
+ fetchS3ObjectByUrl(sourceUrl, {
302
+ recordingId,
303
+ storageMode: "own",
304
+ timeoutMs: FETCH_TIMEOUT_MS,
305
+ }),
306
+ );
307
+ } catch (error) {
308
+ // A bucket that is down or misconfigured still leaves the public URL.
309
+ captureRouteError(error, {
310
+ route: "api/thumbnail",
311
+ extra: { recordingId },
312
+ });
313
+ return null;
314
+ }
315
+ if (!upstream?.ok) {
316
+ // A 5xx is the bucket failing, not a file it lacks, and the public
317
+ // fallback's answer would hide it.
318
+ if (upstream && upstream.status >= 500) {
319
+ captureStorageStatus(upstream.status, recordingId);
320
+ }
321
+ await upstream?.body?.cancel().catch(() => {});
322
+ return null;
323
+ }
324
+ return bucketImageResponse(upstream);
325
+ }
326
+
327
+ function captureStorageStatus(status: number, recordingId: string): void {
328
+ captureRouteError(
329
+ new Error(`Storage provider returned ${status} for thumbnail`),
330
+ {
331
+ route: "api/thumbnail",
332
+ tags: { upstreamStatus: String(status) },
333
+ extra: { recordingId },
334
+ },
335
+ );
336
+ }
337
+
338
+ async function bucketImageResponse(upstream: Response): Promise<Response> {
248
339
  const contentType =
249
340
  upstream.headers
250
341
  .get("content-type")
@@ -283,6 +374,7 @@ async function loadRecording(recordingId: string, event: H3Event) {
283
374
  storageMode: schema.recordings.storageMode,
284
375
  expiresAt: schema.recordings.expiresAt,
285
376
  organizationId: schema.recordings.organizationId,
377
+ ownerEmail: schema.recordings.ownerEmail,
286
378
  password: schema.recordings.password,
287
379
  sharePasswordVersion: schema.recordings.sharePasswordVersion,
288
380
  trashedAt: schema.recordings.trashedAt,
@@ -320,6 +412,7 @@ async function loadRecording(recordingId: string, event: H3Event) {
320
412
  .select({
321
413
  thumbnailUrl: schema.recordings.thumbnailUrl,
322
414
  animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl,
415
+ storageMode: schema.recordings.storageMode,
323
416
  trashedAt: schema.recordings.trashedAt,
324
417
  })
325
418
  .from(schema.recordings)
@@ -336,6 +429,9 @@ async function loadRecording(recordingId: string, event: H3Event) {
336
429
  thumbnailUrl: footage.thumbnailUrl,
337
430
  animatedThumbnailUrl: footage.animatedThumbnailUrl,
338
431
  baseImageUrl: null,
432
+ // Its files sit where the footage was stored; only the access and the
433
+ // owner credentials come from the Clip.
434
+ storageMode: footage.storageMode,
339
435
  },
340
436
  role,
341
437
  gateId,
@@ -451,27 +547,30 @@ export default defineEventHandler(async (event: H3Event) => {
451
547
  }
452
548
 
453
549
  try {
454
- // Imported thumbnails (e.g. Loom's) live outside the bucket, so only
455
- // URLs inside it take the recording-bound private fetch.
456
- const isHostedObject =
457
- recording.storageMode === "hosted" &&
458
- (await isUrlInS3Storage(resolvedSourceUrl, {
459
- storageMode: "hosted",
460
- })) !== false;
461
- const response = isHostedObject
462
- ? await fetchHostedThumbnail(resolvedSourceUrl, recordingId)
463
- : await fetchThumbnail(resolvedSourceUrl);
550
+ let response: Response;
551
+ if (recording.storageMode === "hosted") {
552
+ // Imported thumbnails (e.g. Loom's) live outside the bucket, so only
553
+ // URLs inside it take the recording-bound private fetch.
554
+ const isHostedObject =
555
+ (await isUrlInS3Storage(resolvedSourceUrl, {
556
+ storageMode: "hosted",
557
+ })) !== false;
558
+ response = isHostedObject
559
+ ? await fetchHostedThumbnail(resolvedSourceUrl, recordingId)
560
+ : await fetchThumbnail(resolvedSourceUrl);
561
+ } else {
562
+ // Both reads share one budget: a bucket that hangs would otherwise
563
+ // make the public fallback wait out a second timeout.
564
+ const deadline = Date.now() + FETCH_TIMEOUT_MS;
565
+ response =
566
+ (await fetchOwnBucketThumbnail(
567
+ resolvedSourceUrl,
568
+ recordingId,
569
+ recording,
570
+ )) ?? (await fetchThumbnail(resolvedSourceUrl, deadline));
571
+ }
464
572
  if (response.status >= 500) {
465
- captureRouteError(
466
- new Error(
467
- `Storage provider returned ${response.status} for thumbnail`,
468
- ),
469
- {
470
- route: "api/thumbnail",
471
- tags: { upstreamStatus: String(response.status) },
472
- extra: { recordingId },
473
- },
474
- );
573
+ captureStorageStatus(response.status, recordingId);
475
574
  }
476
575
  const contentType = response.headers
477
576
  .get("content-type")
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","collab","core-routes","creative-context","db","feature-flags","labs","onboarding","otel","suggested-edits","transactional-emails"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -1,3 +1,4 @@
1
+ import type { EngineModelGroup } from "@agent-native/core/client/agent-chat";
1
2
  import { useT } from "@agent-native/core/client/i18n";
2
3
  import { AgentMark } from "@agent-native/toolkit/app/shared";
3
4
  import { IconCheck, IconChevronDown } from "@tabler/icons-react";
@@ -25,6 +26,34 @@ export interface CommentAiSelection {
25
26
  export const commentAiSelectionKey = (selection: CommentAiSelection) =>
26
27
  `${selection.engine}:${selection.model}`;
27
28
 
29
+ /**
30
+ * One selection per model name. With Builder.io connected, a provider key
31
+ * adds its own group, and the two name one model differently ("gpt-5-4",
32
+ * "gpt-5.4"), so ids alone can't dedupe them. The current choice wins, then
33
+ * its engine, then the first group listed.
34
+ */
35
+ export function commentAiSelections(
36
+ groups: readonly Pick<EngineModelGroup, "engine" | "label" | "models">[],
37
+ current: { model: string; engine: string },
38
+ ): CommentAiSelection[] {
39
+ const rank = (selection: CommentAiSelection) =>
40
+ selection.engine !== current.engine
41
+ ? 0
42
+ : selection.model === current.model
43
+ ? 2
44
+ : 1;
45
+ const byName = new Map<string, CommentAiSelection>();
46
+ for (const group of groups) {
47
+ for (const model of group.models) {
48
+ const selection = { model, engine: group.engine, provider: group.label };
49
+ const name = modelDisplayName(model);
50
+ const kept = byName.get(name);
51
+ if (!kept || rank(selection) > rank(kept)) byName.set(name, selection);
52
+ }
53
+ }
54
+ return [...byName.values()];
55
+ }
56
+
28
57
  /** Words a person might type after `@` to find a model, e.g. "Sonnet". */
29
58
  export function modelAliases(model: string): string[] {
30
59
  const name = modelDisplayName(model);