@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/clips/actions/delete-recording-permanent.ts +97 -72
- package/corpus/templates/clips/app/components/player/transcript-import-button.tsx +9 -2
- package/corpus/templates/clips/changelog/2026-10-16-if-permanently-deleting-a-clip-fails-the-clip-and-its-video-.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-16-the-replace-transcript-dialog-keeps-showing-the-file-name-wh.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-16-tooltips-no-longer-pop-up-on-their-own-after-a-menu-popover-.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-16-video-posters-load-again-when-your-own-storage-runs-on-a-pri.md +6 -0
- package/corpus/templates/clips/server/lib/upload-recording-media.ts +5 -2
- package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +123 -24
- package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/content/app/components/editor/CommentAiRecipient.tsx +29 -0
- package/corpus/templates/content/app/components/editor/CommentComposer.tsx +6 -8
- package/corpus/templates/content/changelog/2026-10-15-the-comment-menu-lists-each-ai-model-once-instead-of-once-pe.md +6 -0
- package/corpus/templates/content/scripts/stored-form-census-kinds.ts +931 -0
- package/corpus/templates/content/scripts/stored-form-census-scan.ts +814 -0
- package/corpus/templates/content/scripts/stored-form-census.ts +505 -0
- package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.generated/bridge/native-builtin-metadata.generated.ts +301 -0
- package/corpus/templates/design/.generated/bridge/native-worker-source.generated.ts +6 -0
- package/corpus/templates/design/actions/update-file.ts +10 -15
- package/corpus/templates/design/changelog/2026-10-20-public-design-previews-no-longer-show-spurious-export-errors.md +6 -0
- package/corpus/templates/design/changelog/2026-10-20-screen-image-uploads-no-longer-overwrite-edits-made-during-t.md +6 -0
- package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/mail/actions/list-inbox-threads.ts +221 -57
- package/corpus/templates/mail/actions/resync-inbox.ts +1 -0
- package/corpus/templates/mail/actions/sync-inbox.ts +9 -3
- package/corpus/templates/mail/app/components/email/EmailList.tsx +44 -10
- package/corpus/templates/mail/app/components/email/EmailListItem.tsx +53 -1
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +5 -2
- package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +63 -3
- package/corpus/templates/mail/app/i18n/en-US.ts +3 -0
- package/corpus/templates/mail/app/lib/thread-cache.ts +3 -0
- package/corpus/templates/mail/app/pages/InboxPage.tsx +6 -0
- package/corpus/templates/mail/app/root.tsx +7 -2
- package/corpus/templates/mail/changelog/2026-10-20-mail-s-inbox-loads-faster-opens-threads-sooner-after-you-hov.md +6 -0
- package/corpus/templates/mail/server/db/schema.ts +3 -0
- package/corpus/templates/mail/server/lib/inbox-push-watch.ts +35 -0
- package/corpus/templates/mail/server/lib/inbox-store.ts +254 -1
- package/corpus/templates/mail/server/lib/inbox-sync.ts +66 -11
- package/corpus/templates/mail/server/lib/inbox-tab-windows.fake.ts +67 -0
- package/corpus/templates/mail/server/plugins/db.ts +5 -0
- package/corpus/templates/mail/shared/inbox-threads.ts +52 -0
- package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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:
|
|
220
|
+
fileName: shownReplacement?.fileName ?? "",
|
|
214
221
|
})}
|
|
215
222
|
</AlertDialogDescription>
|
|
216
223
|
</AlertDialogHeader>
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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(),
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
recording
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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);
|