@agent-native/core 0.157.0 → 0.157.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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +19 -4
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +7 -2
- package/corpus/templates/assets/app/global.css +4 -0
- package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +1 -2
- package/corpus/templates/brain/app/routes/sources.tsx +1 -2
- package/corpus/templates/calendar/app/hooks/use-bookings.ts +12 -2
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +64 -2
- package/corpus/templates/calendar/server/handlers/bookings.ts +224 -93
- package/corpus/templates/calendar/server/plugins/auth.ts +0 -2
- package/corpus/templates/clips/actions/create-folder.ts +7 -1
- package/corpus/templates/clips/actions/create-meeting.ts +6 -2
- package/corpus/templates/clips/actions/create-recording.ts +5 -3
- package/corpus/templates/clips/actions/finalize-recording.ts +97 -65
- package/corpus/templates/clips/actions/import-loom-recording.ts +5 -3
- package/corpus/templates/clips/actions/request-recording-access.ts +305 -0
- package/corpus/templates/clips/actions/stitch-recordings.ts +5 -3
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +38 -63
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +75 -75
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +3 -21
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +13 -30
- package/corpus/templates/clips/app/i18n/en-US.ts +10 -0
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +5 -5
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +9 -16
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +135 -18
- package/corpus/templates/clips/changelog/2026-08-14-long-resumable-uploads-now-abort-cleanly-and-show-errors.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-14-standardized-share-popovers.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +11 -3
- package/corpus/templates/clips/chrome-extension/src/github-preview-content.ts +37 -8
- package/corpus/templates/clips/chrome-extension/src/github-preview.ts +3 -3
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +8 -25
- package/corpus/templates/clips/desktop/src/app.tsx +24 -0
- package/corpus/templates/clips/desktop/src/lib/media-capture-constraints.ts +42 -13
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +11 -18
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +1 -0
- package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
- package/corpus/templates/clips/desktop/src/styles.css +24 -9
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +3 -0
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +8 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +154 -176
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +115 -31
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +7 -0
- package/corpus/templates/clips/server/db/index.ts +1 -0
- package/corpus/templates/clips/server/db/schema.ts +1 -0
- package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +6 -1
- package/corpus/templates/clips/server/lib/emails.ts +23 -0
- package/corpus/templates/clips/server/lib/public-agent-context.ts +55 -0
- package/corpus/templates/clips/server/lib/recordings.ts +2 -1
- package/corpus/templates/clips/server/lib/resumable-upload-cleanup.ts +49 -0
- package/corpus/templates/clips/server/lib/s3-upload-provider.ts +34 -7
- package/corpus/templates/clips/server/lib/upload-interruption.ts +15 -1
- package/corpus/templates/clips/server/lib/upload-lease.ts +94 -9
- package/corpus/templates/clips/server/routes/api/agent-context.json.get.ts +82 -7
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +1 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +71 -12
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +4 -3
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +133 -4
- package/corpus/templates/clips/server/routes/api/uploads/reap.post.ts +8 -1
- package/corpus/templates/clips/shared/recording-link.ts +4 -0
- package/corpus/templates/clips/shared/share-loader-response.ts +2 -1
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +7 -16
- package/corpus/templates/factory/AGENTS.md +5 -1
- package/corpus/templates/factory/README.md +6 -2
- package/corpus/templates/factory/actions/view-screen.ts +15 -0
- package/corpus/templates/factory/app/components/factory/FactoryAgentsView.tsx +58 -189
- package/corpus/templates/factory/app/components/layout/Header.tsx +1 -0
- package/corpus/templates/factory/app/components/layout/Layout.tsx +1 -1
- package/corpus/templates/factory/app/components/layout/Sidebar.tsx +7 -0
- package/corpus/templates/factory/app/hooks/use-navigation-state.ts +3 -0
- package/corpus/templates/factory/app/i18n/en-US.ts +3 -1
- package/corpus/templates/factory/app/routes/agents.tsx +9 -0
- package/corpus/templates/mail/server/plugins/auth.ts +0 -2
- package/corpus/templates/plan/app/components/plan/LocalCodebasePicker.tsx +50 -14
- package/corpus/templates/plan/app/pages/PlansPage.tsx +9 -19
- package/corpus/templates/slides/app/components/editor/HistoryPanel.tsx +7 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +71 -22
- package/corpus/templates/slides/app/lib/editor-shortcuts.ts +46 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +13 -0
- package/dist/client/AgentPanel.d.ts +13 -1
- package/dist/client/AgentPanel.js +12 -10
- package/dist/client/AssistantChat.js +25 -5
- package/dist/client/HighlightedCodeBlock.js +12 -2
- package/dist/client/agent-chat/index.d.ts +1 -0
- package/dist/client/agent-chat/index.js +1 -0
- package/dist/client/chat/markdown-renderer.js +16 -3
- package/dist/client/chat-first/apps-rail.js +1 -1
- package/dist/client/components/ui/message-scroller.js +1 -1
- package/dist/client/settings/AccountSettingsCard.js +48 -19
- package/dist/client/sharing/ShareButton.d.ts +1 -2
- package/dist/client/sharing/ShareButton.js +5 -41
- package/dist/client/sharing/ShareDialog.js +5 -4
- package/dist/client/use-chat-threads.js +10 -14
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +1 -0
- package/dist/localization/default-messages.js +1 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +15 -15
- package/dist/server/auth-marketing.d.ts +1 -0
- package/dist/server/auth-marketing.js +0 -2
- package/dist/server/auth.d.ts +1 -0
- package/dist/server/auth.js +18 -0
- package/dist/server/onboarding-html.d.ts +1 -0
- package/dist/server/onboarding-html.js +2 -88
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/shared/markdown-block-split.d.ts +16 -0
- package/dist/shared/markdown-block-split.js +81 -5
- package/dist/styles/agent-native.css +13 -0
- package/dist/voice/voice-cleanup-prompt.js +1 -0
- package/docs/content/cloneable-saas.mdx +24 -26
- package/package.json +3 -3
- package/corpus/templates/clips/shared/mic-audio-cleanup.ts +0 -350
|
@@ -784,13 +784,40 @@ export const s3FileUploadProvider: FileUploadProvider = {
|
|
|
784
784
|
)
|
|
785
785
|
.join("") +
|
|
786
786
|
"</CompleteMultipartUpload>";
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
787
|
+
let res: Response;
|
|
788
|
+
try {
|
|
789
|
+
res = await signedS3Request(cfg, meta.objectKey, {
|
|
790
|
+
method: "POST",
|
|
791
|
+
query: { uploadId: session.sessionId },
|
|
792
|
+
body: new TextEncoder().encode(manifest),
|
|
793
|
+
contentType: "application/xml",
|
|
794
|
+
timeoutMs: S3_PUT_TIMEOUT_MS,
|
|
795
|
+
});
|
|
796
|
+
} catch (error) {
|
|
797
|
+
// R2 can finish the object while the HTTP response is lost. Reconcile
|
|
798
|
+
// that ambiguous case before the caller aborts the provider session.
|
|
799
|
+
try {
|
|
800
|
+
if (await verifyCompletedMultipartObject(cfg, meta)) {
|
|
801
|
+
await deleteObject(cfg, meta.stagingKey).catch((cleanupError) => {
|
|
802
|
+
console.warn(
|
|
803
|
+
"[s3-upload] failed to delete multipart staging object:",
|
|
804
|
+
cleanupError instanceof Error
|
|
805
|
+
? cleanupError.message
|
|
806
|
+
: String(cleanupError),
|
|
807
|
+
);
|
|
808
|
+
});
|
|
809
|
+
return publicObjectUrl(cfg, meta.objectKey);
|
|
810
|
+
}
|
|
811
|
+
} catch (verificationError) {
|
|
812
|
+
console.warn(
|
|
813
|
+
"[s3-upload] completion reconciliation failed:",
|
|
814
|
+
verificationError,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
throw new Error(
|
|
818
|
+
`S3 CompleteMultipartUpload failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
794
821
|
const body = await res.text().catch(() => "");
|
|
795
822
|
if (!res.ok || /<Error(?:\s|>)/.test(body)) {
|
|
796
823
|
// CompleteMultipartUpload is not idempotent at the S3 API level. If
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
export const RETRYABLE_UPLOAD_INTERRUPTION_REASON =
|
|
2
2
|
"Upload was interrupted. The local recording is safe; retry from the Clips desktop app.";
|
|
3
3
|
|
|
4
|
+
export function retryableUploadInterruptionReason(
|
|
5
|
+
detail: string | null | undefined,
|
|
6
|
+
): string {
|
|
7
|
+
const normalized = detail?.trim();
|
|
8
|
+
return normalized
|
|
9
|
+
? `${RETRYABLE_UPLOAD_INTERRUPTION_REASON} Last error: ${normalized}`
|
|
10
|
+
: RETRYABLE_UPLOAD_INTERRUPTION_REASON;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
export function isRetryableUploadInterruption(
|
|
5
14
|
failureReason: string | null | undefined,
|
|
6
15
|
): boolean {
|
|
7
|
-
return
|
|
16
|
+
return (
|
|
17
|
+
failureReason === RETRYABLE_UPLOAD_INTERRUPTION_REASON ||
|
|
18
|
+
failureReason?.startsWith(
|
|
19
|
+
`${RETRYABLE_UPLOAD_INTERRUPTION_REASON} Last error:`,
|
|
20
|
+
) === true
|
|
21
|
+
);
|
|
8
22
|
}
|
|
@@ -15,6 +15,8 @@ import { getDbExec, isPostgres } from "@agent-native/core/db";
|
|
|
15
15
|
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
16
16
|
|
|
17
17
|
import { getDb, schema } from "../db/index.js";
|
|
18
|
+
import type { StoredResumableSession } from "./resumable-session.js";
|
|
19
|
+
import { abortResumableUploadSession } from "./resumable-upload-cleanup.js";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* ponytail: one horizon for both in-progress statuses. A paused recorder emits
|
|
@@ -148,6 +150,61 @@ export interface ReapResult {
|
|
|
148
150
|
expired: ReapedUpload[];
|
|
149
151
|
failed: number;
|
|
150
152
|
scratchKeysDeleted: number;
|
|
153
|
+
resumableSessionsAborted: number;
|
|
154
|
+
resumableCleanupFailed: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resumableSessionKey(
|
|
158
|
+
recordingId: string,
|
|
159
|
+
generationId: string | null,
|
|
160
|
+
): string {
|
|
161
|
+
return generationId
|
|
162
|
+
? `resumable-session-${recordingId}-${generationId}`
|
|
163
|
+
: `resumable-session-${recordingId}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseStoredResumableSession(
|
|
167
|
+
value: unknown,
|
|
168
|
+
): StoredResumableSession | null {
|
|
169
|
+
let parsed: unknown = value;
|
|
170
|
+
if (typeof parsed === "string") {
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(parsed);
|
|
173
|
+
} catch {
|
|
174
|
+
// coercion-ok: malformed persisted session is absent, not an active session.
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
179
|
+
const candidate = parsed as Record<string, unknown>;
|
|
180
|
+
if (
|
|
181
|
+
typeof candidate.providerId !== "string" ||
|
|
182
|
+
typeof candidate.sessionId !== "string" ||
|
|
183
|
+
!candidate.meta ||
|
|
184
|
+
typeof candidate.meta !== "object" ||
|
|
185
|
+
typeof candidate.bytesUploaded !== "number" ||
|
|
186
|
+
!Number.isFinite(candidate.bytesUploaded) ||
|
|
187
|
+
candidate.bytesUploaded < 0
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return candidate as unknown as StoredResumableSession;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function readResumableSessionState(
|
|
195
|
+
exec: ReturnType<typeof getDbExec>,
|
|
196
|
+
placeholder: string,
|
|
197
|
+
key: string,
|
|
198
|
+
): Promise<{ present: boolean; session: StoredResumableSession | null }> {
|
|
199
|
+
const result = await exec.execute({
|
|
200
|
+
sql: `SELECT value FROM application_state WHERE key = ${placeholder}`,
|
|
201
|
+
args: [key],
|
|
202
|
+
});
|
|
203
|
+
const row = (result.rows as Array<{ value?: unknown }> | undefined)?.[0];
|
|
204
|
+
return {
|
|
205
|
+
present: row !== undefined,
|
|
206
|
+
session: row === undefined ? null : parseStoredResumableSession(row.value),
|
|
207
|
+
};
|
|
151
208
|
}
|
|
152
209
|
|
|
153
210
|
/**
|
|
@@ -196,6 +253,8 @@ export async function reapExpiredUploads(
|
|
|
196
253
|
}));
|
|
197
254
|
|
|
198
255
|
let failed = 0;
|
|
256
|
+
let resumableSessionsAborted = 0;
|
|
257
|
+
let resumableCleanupFailed = 0;
|
|
199
258
|
if (expired.length > 0 && !dryRun) {
|
|
200
259
|
const ids = expired.map((row) => row.id);
|
|
201
260
|
const result = await exec.execute({
|
|
@@ -223,16 +282,35 @@ export async function reapExpiredUploads(
|
|
|
223
282
|
failed = terminated.size;
|
|
224
283
|
|
|
225
284
|
for (const id of terminated) {
|
|
226
|
-
const generationId =
|
|
227
|
-
(row) => row.id === id
|
|
228
|
-
)
|
|
285
|
+
const generationId =
|
|
286
|
+
expired.find((row) => row.id === id)?.uploadGenerationId ?? null;
|
|
287
|
+
const sessionKey = resumableSessionKey(id, generationId);
|
|
288
|
+
const sessionState = await readResumableSessionState(
|
|
289
|
+
exec,
|
|
290
|
+
p(1),
|
|
291
|
+
sessionKey,
|
|
292
|
+
);
|
|
293
|
+
if (sessionState.present && !sessionState.session) {
|
|
294
|
+
resumableCleanupFailed += 1;
|
|
295
|
+
console.warn(
|
|
296
|
+
`[upload-reaper-${id}] resumable session state is unreadable; keeping it for manual cleanup`,
|
|
297
|
+
);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (sessionState.session) {
|
|
301
|
+
const cleaned = await abortResumableUploadSession(
|
|
302
|
+
sessionState.session,
|
|
303
|
+
{ label: `upload-reaper-${id}` },
|
|
304
|
+
);
|
|
305
|
+
if (!cleaned) {
|
|
306
|
+
resumableCleanupFailed += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
resumableSessionsAborted += 1;
|
|
310
|
+
}
|
|
229
311
|
await exec.execute({
|
|
230
312
|
sql: `DELETE FROM application_state WHERE key = ${p(1)}`,
|
|
231
|
-
args: [
|
|
232
|
-
generationId
|
|
233
|
-
? `resumable-session-${id}-${generationId}`
|
|
234
|
-
: `resumable-session-${id}`,
|
|
235
|
-
],
|
|
313
|
+
args: [sessionKey],
|
|
236
314
|
});
|
|
237
315
|
}
|
|
238
316
|
}
|
|
@@ -241,7 +319,14 @@ export async function reapExpiredUploads(
|
|
|
241
319
|
? (await selectUnclaimedChunkKeys(limit)).length
|
|
242
320
|
: await deleteUnclaimedChunkScratch(limit);
|
|
243
321
|
|
|
244
|
-
return {
|
|
322
|
+
return {
|
|
323
|
+
dryRun,
|
|
324
|
+
expired,
|
|
325
|
+
failed,
|
|
326
|
+
scratchKeysDeleted,
|
|
327
|
+
resumableSessionsAborted,
|
|
328
|
+
resumableCleanupFailed,
|
|
329
|
+
};
|
|
245
330
|
}
|
|
246
331
|
|
|
247
332
|
/**
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* smaller agent-oriented shape plus discoverable transcript/frame APIs.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { asc, count, eq } from "drizzle-orm";
|
|
9
10
|
import {
|
|
10
11
|
defineEventHandler,
|
|
11
12
|
getQuery,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
type H3Event,
|
|
14
15
|
} from "h3";
|
|
15
16
|
|
|
17
|
+
import { getDb, schema } from "../../db/index.js";
|
|
16
18
|
import {
|
|
17
19
|
applyAgentJsonHeaders,
|
|
18
20
|
buildPublicAgentContext,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
loadAgentCtas,
|
|
22
24
|
loadAgentTranscript,
|
|
23
25
|
loadPublicAgentAccess,
|
|
26
|
+
MAX_PUBLIC_AGENT_HISTORY_ITEMS,
|
|
24
27
|
parseAgentChapters,
|
|
25
28
|
queryString,
|
|
26
29
|
CLIPS_AGENT_ACCESS_PARAM,
|
|
@@ -42,14 +45,76 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const recording = accessResult.access.recording;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const db = getDb();
|
|
49
|
+
const historyQueryLimit = MAX_PUBLIC_AGENT_HISTORY_ITEMS + 1;
|
|
50
|
+
const [
|
|
51
|
+
{ transcript, agentSegments },
|
|
52
|
+
commentRows,
|
|
53
|
+
reactionRows,
|
|
54
|
+
commentCountRows,
|
|
55
|
+
reactionCountRows,
|
|
56
|
+
ctas,
|
|
57
|
+
browserDiagnostics,
|
|
58
|
+
bugReport,
|
|
59
|
+
] = await Promise.all([
|
|
60
|
+
loadAgentTranscript(recording.id, recording.durationMs),
|
|
61
|
+
recording.enableComments
|
|
62
|
+
? db
|
|
63
|
+
.select({
|
|
64
|
+
id: schema.recordingComments.id,
|
|
65
|
+
recordingId: schema.recordingComments.recordingId,
|
|
66
|
+
threadId: schema.recordingComments.threadId,
|
|
67
|
+
parentId: schema.recordingComments.parentId,
|
|
68
|
+
authorName: schema.recordingComments.authorName,
|
|
69
|
+
content: schema.recordingComments.content,
|
|
70
|
+
videoTimestampMs: schema.recordingComments.videoTimestampMs,
|
|
71
|
+
resolved: schema.recordingComments.resolved,
|
|
72
|
+
createdAt: schema.recordingComments.createdAt,
|
|
73
|
+
updatedAt: schema.recordingComments.updatedAt,
|
|
74
|
+
})
|
|
75
|
+
.from(schema.recordingComments)
|
|
76
|
+
.where(eq(schema.recordingComments.recordingId, recording.id))
|
|
77
|
+
.orderBy(
|
|
78
|
+
asc(schema.recordingComments.videoTimestampMs),
|
|
79
|
+
asc(schema.recordingComments.createdAt),
|
|
80
|
+
)
|
|
81
|
+
.limit(historyQueryLimit)
|
|
82
|
+
: Promise.resolve([]),
|
|
83
|
+
recording.enableReactions
|
|
84
|
+
? db
|
|
85
|
+
.select({
|
|
86
|
+
id: schema.recordingReactions.id,
|
|
87
|
+
emoji: schema.recordingReactions.emoji,
|
|
88
|
+
videoTimestampMs: schema.recordingReactions.videoTimestampMs,
|
|
89
|
+
viewerName: schema.recordingReactions.viewerName,
|
|
90
|
+
createdAt: schema.recordingReactions.createdAt,
|
|
91
|
+
})
|
|
92
|
+
.from(schema.recordingReactions)
|
|
93
|
+
.where(eq(schema.recordingReactions.recordingId, recording.id))
|
|
94
|
+
.orderBy(asc(schema.recordingReactions.createdAt))
|
|
95
|
+
.limit(historyQueryLimit)
|
|
96
|
+
: Promise.resolve([]),
|
|
97
|
+
recording.enableComments
|
|
98
|
+
? db
|
|
99
|
+
.select({ count: count() })
|
|
100
|
+
.from(schema.recordingComments)
|
|
101
|
+
.where(eq(schema.recordingComments.recordingId, recording.id))
|
|
102
|
+
: Promise.resolve([{ count: 0 }]),
|
|
103
|
+
recording.enableReactions
|
|
104
|
+
? db
|
|
105
|
+
.select({ count: count() })
|
|
106
|
+
.from(schema.recordingReactions)
|
|
107
|
+
.where(eq(schema.recordingReactions.recordingId, recording.id))
|
|
108
|
+
: Promise.resolve([{ count: 0 }]),
|
|
109
|
+
loadAgentCtas(recording.id),
|
|
110
|
+
loadAgentBrowserDiagnostics(recording.id),
|
|
111
|
+
loadAgentBugReport(recording.id),
|
|
112
|
+
]);
|
|
52
113
|
const chapters = parseAgentChapters(recording);
|
|
114
|
+
const comments = commentRows.slice(0, MAX_PUBLIC_AGENT_HISTORY_ITEMS);
|
|
115
|
+
const reactions = reactionRows.slice(0, MAX_PUBLIC_AGENT_HISTORY_ITEMS);
|
|
116
|
+
const commentCount = Number(commentCountRows[0]?.count ?? comments.length);
|
|
117
|
+
const reactionCount = Number(reactionCountRows[0]?.count ?? reactions.length);
|
|
53
118
|
|
|
54
119
|
return buildPublicAgentContext({
|
|
55
120
|
event,
|
|
@@ -57,6 +122,16 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
57
122
|
transcript,
|
|
58
123
|
agentSegments,
|
|
59
124
|
chapters,
|
|
125
|
+
comments,
|
|
126
|
+
reactions,
|
|
127
|
+
commentCount,
|
|
128
|
+
commentsTruncated:
|
|
129
|
+
commentRows.length > MAX_PUBLIC_AGENT_HISTORY_ITEMS ||
|
|
130
|
+
commentCount > comments.length,
|
|
131
|
+
reactionCount,
|
|
132
|
+
reactionsTruncated:
|
|
133
|
+
reactionRows.length > MAX_PUBLIC_AGENT_HISTORY_ITEMS ||
|
|
134
|
+
reactionCount > reactions.length,
|
|
60
135
|
ctas,
|
|
61
136
|
browserDiagnostics,
|
|
62
137
|
bugReport,
|
|
@@ -263,6 +263,7 @@ export default defineEventHandler(async (event) => {
|
|
|
263
263
|
!viewerIsOrgMember &&
|
|
264
264
|
!tokenAllowsAgentAccess
|
|
265
265
|
) {
|
|
266
|
+
setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
|
|
266
267
|
setResponseStatus(event, 404);
|
|
267
268
|
return { error: "Not found" };
|
|
268
269
|
}
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
setResumableSession,
|
|
53
53
|
type StoredResumableSession,
|
|
54
54
|
} from "../../../../lib/resumable-session.js";
|
|
55
|
+
import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
|
|
55
56
|
import { resolveResumableUploadProvider } from "../../../../lib/resumable-upload-provider.js";
|
|
56
57
|
import { isStreamingUploadDisabled } from "../../../../lib/streaming-upload-mode.js";
|
|
57
58
|
import { renewUploadLease } from "../../../../lib/upload-lease.js";
|
|
@@ -854,6 +855,23 @@ async function handleResumableChunk(
|
|
|
854
855
|
`[resumable-chunk-${recordingId}] resumable session exists - bytesUploaded=${session.bytesUploaded} index=${index} isFinal=${isFinal}`,
|
|
855
856
|
);
|
|
856
857
|
|
|
858
|
+
const cleanupFailedFinalSession = async () => {
|
|
859
|
+
const cleaned = await abortResumableUploadSession(session, {
|
|
860
|
+
provider: uploadProvider,
|
|
861
|
+
label: `resumable-final-${recordingId}`,
|
|
862
|
+
});
|
|
863
|
+
if (cleaned) {
|
|
864
|
+
await deleteResumableSession(recordingId, uploadGenerationId).catch(
|
|
865
|
+
(error) =>
|
|
866
|
+
console.warn(
|
|
867
|
+
`[resumable-chunk-${recordingId}] failed to retire aborted session:`,
|
|
868
|
+
error,
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
return !cleaned;
|
|
873
|
+
};
|
|
874
|
+
|
|
857
875
|
const raw = await readRawBody(event, false);
|
|
858
876
|
const bytes: Uint8Array = raw ?? new Uint8Array(0);
|
|
859
877
|
let finalizedSourceSizeBytes = session.bytesUploaded;
|
|
@@ -884,19 +902,37 @@ async function handleResumableChunk(
|
|
|
884
902
|
// 0-byte sentinel from the recorder after stop(). All data chunks have
|
|
885
903
|
// already been PUT to the provider; send Content-Range: bytes */<total>
|
|
886
904
|
// to close the session before handing off to finalize-recording.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
905
|
+
let closeRes;
|
|
906
|
+
try {
|
|
907
|
+
closeRes = await uploadProvider.resumable.relayChunk(
|
|
908
|
+
{ sessionId: session.sessionId, meta: session.meta },
|
|
909
|
+
`bytes */${session.bytesUploaded}`,
|
|
910
|
+
new Uint8Array(0),
|
|
911
|
+
);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
const cleanupFailed = await cleanupFailedFinalSession();
|
|
914
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
915
|
+
console.error(
|
|
916
|
+
`[resumable-chunk-${recordingId}] session close threw:`,
|
|
917
|
+
error,
|
|
918
|
+
);
|
|
919
|
+
setResponseStatus(event, 502);
|
|
920
|
+
return {
|
|
921
|
+
ok: false,
|
|
922
|
+
error: `Resumable session close failed: ${detail}`,
|
|
923
|
+
...(cleanupFailed ? { cleanupFailed: true } : {}),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
892
926
|
if (!closeRes.ok || closeRes.status === 308) {
|
|
893
927
|
console.error(
|
|
894
928
|
`[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
|
|
895
929
|
);
|
|
930
|
+
const cleanupFailed = await cleanupFailedFinalSession();
|
|
896
931
|
setResponseStatus(event, 502);
|
|
897
932
|
return {
|
|
898
933
|
ok: false,
|
|
899
934
|
error: `Resumable session close failed (${closeRes.status})`,
|
|
935
|
+
...(cleanupFailed ? { cleanupFailed: true } : {}),
|
|
900
936
|
};
|
|
901
937
|
}
|
|
902
938
|
if (closeRes.updatedMeta) {
|
|
@@ -964,12 +1000,31 @@ async function handleResumableChunk(
|
|
|
964
1000
|
: `bytes ${start}-${end}/*`;
|
|
965
1001
|
|
|
966
1002
|
const putT0 = Date.now();
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1003
|
+
let putResult;
|
|
1004
|
+
try {
|
|
1005
|
+
putResult = await uploadProvider.resumable.relayChunk(
|
|
1006
|
+
{ sessionId: session.sessionId, meta: session.meta },
|
|
1007
|
+
contentRange,
|
|
1008
|
+
bytes,
|
|
1009
|
+
{ mimeType: mimeType.split(";")[0].trim() },
|
|
1010
|
+
);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (isFinal) {
|
|
1013
|
+
const cleanupFailed = await cleanupFailedFinalSession();
|
|
1014
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1015
|
+
console.error(
|
|
1016
|
+
`[resumable-chunk-${recordingId}] final chunk upload threw:`,
|
|
1017
|
+
error,
|
|
1018
|
+
);
|
|
1019
|
+
setResponseStatus(event, 502);
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
error: `Final chunk upload failed: ${detail}`,
|
|
1023
|
+
...(cleanupFailed ? { cleanupFailed: true } : {}),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
973
1028
|
console.log(
|
|
974
1029
|
`[resumable-chunk-${recordingId}] PUT ${Date.now() - putT0}ms status=${putResult.status} range="${contentRange}"`,
|
|
975
1030
|
);
|
|
@@ -980,7 +1035,10 @@ async function handleResumableChunk(
|
|
|
980
1035
|
if (!resultOk) {
|
|
981
1036
|
const restartRequired =
|
|
982
1037
|
putResult.status === 404 || putResult.status === 410;
|
|
983
|
-
|
|
1038
|
+
const cleanupFailed = isFinal
|
|
1039
|
+
? await cleanupFailedFinalSession()
|
|
1040
|
+
: false;
|
|
1041
|
+
if (restartRequired && !cleanupFailed) {
|
|
984
1042
|
await deleteResumableSession(recordingId, uploadGenerationId).catch(
|
|
985
1043
|
() => {},
|
|
986
1044
|
);
|
|
@@ -990,6 +1048,7 @@ async function handleResumableChunk(
|
|
|
990
1048
|
ok: false,
|
|
991
1049
|
error: `Chunk upload failed (${putResult.status})`,
|
|
992
1050
|
...(restartRequired ? { restartRequired: true } : {}),
|
|
1051
|
+
...(cleanupFailed ? { cleanupFailed: true } : {}),
|
|
993
1052
|
};
|
|
994
1053
|
}
|
|
995
1054
|
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from "../../../../lib/recordings.js";
|
|
32
32
|
import {
|
|
33
33
|
isRetryableUploadInterruption,
|
|
34
|
-
|
|
34
|
+
retryableUploadInterruptionReason,
|
|
35
35
|
} from "../../../../lib/upload-interruption.js";
|
|
36
36
|
import abortUpload from "./abort.post.js";
|
|
37
37
|
|
|
@@ -131,12 +131,13 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
131
131
|
const uploadStateRaw = await readAppState(uploadStateKey);
|
|
132
132
|
const uploadState = uploadStateRaw ?? {};
|
|
133
133
|
const interruptedAt = new Date().toISOString();
|
|
134
|
+
const failureReason = retryableUploadInterruptionReason(interruptionDetail);
|
|
134
135
|
if (!alreadyInterrupted) {
|
|
135
136
|
const interrupted = await db
|
|
136
137
|
.update(schema.recordings)
|
|
137
138
|
.set({
|
|
138
139
|
status: "failed",
|
|
139
|
-
failureReason
|
|
140
|
+
failureReason,
|
|
140
141
|
updatedAt: interruptedAt,
|
|
141
142
|
})
|
|
142
143
|
.where(
|
|
@@ -167,7 +168,7 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
167
168
|
...uploadState,
|
|
168
169
|
recordingId,
|
|
169
170
|
status: "failed",
|
|
170
|
-
failureReason
|
|
171
|
+
failureReason,
|
|
171
172
|
retryableInterruption: true,
|
|
172
173
|
uploadAttemptId: attemptId,
|
|
173
174
|
uploadGenerationId,
|
|
@@ -55,8 +55,11 @@ import {
|
|
|
55
55
|
} from "../../../../lib/recordings.js";
|
|
56
56
|
import {
|
|
57
57
|
deleteResumableSession,
|
|
58
|
+
getResumableSession,
|
|
58
59
|
setResumableSession,
|
|
60
|
+
type StoredResumableSession,
|
|
59
61
|
} from "../../../../lib/resumable-session.js";
|
|
62
|
+
import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
|
|
60
63
|
import { shouldEnableStreamingUpload } from "../../../../lib/streaming-upload-mode.js";
|
|
61
64
|
import {
|
|
62
65
|
renewUploadLease,
|
|
@@ -72,6 +75,66 @@ interface CompressionMeta {
|
|
|
72
75
|
outputMimeType?: string;
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
interface PendingResumableCleanup {
|
|
79
|
+
recordingId: string;
|
|
80
|
+
generationId: string | null;
|
|
81
|
+
ownerGenerationId: string | null;
|
|
82
|
+
claimId: string | null;
|
|
83
|
+
session: StoredResumableSession;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parsePendingResumableCleanup(
|
|
87
|
+
raw: Record<string, unknown> | null,
|
|
88
|
+
recordingId: string,
|
|
89
|
+
): PendingResumableCleanup | null {
|
|
90
|
+
if (!raw) return null;
|
|
91
|
+
const session = raw.session;
|
|
92
|
+
const generationId = raw.generationId;
|
|
93
|
+
const ownerGenerationId = raw.ownerGenerationId;
|
|
94
|
+
const claimId = raw.claimId;
|
|
95
|
+
if (
|
|
96
|
+
raw.recordingId !== recordingId ||
|
|
97
|
+
!session ||
|
|
98
|
+
typeof session !== "object" ||
|
|
99
|
+
Array.isArray(session) ||
|
|
100
|
+
!(generationId === null || typeof generationId === "string") ||
|
|
101
|
+
(ownerGenerationId !== undefined &&
|
|
102
|
+
ownerGenerationId !== null &&
|
|
103
|
+
typeof ownerGenerationId !== "string") ||
|
|
104
|
+
(claimId !== undefined && claimId !== null && typeof claimId !== "string")
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Invalid resumable cleanup state for recording ${recordingId}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidate = session as Record<string, unknown>;
|
|
112
|
+
if (
|
|
113
|
+
typeof candidate.providerId !== "string" ||
|
|
114
|
+
typeof candidate.sessionId !== "string" ||
|
|
115
|
+
!candidate.meta ||
|
|
116
|
+
typeof candidate.meta !== "object" ||
|
|
117
|
+
Array.isArray(candidate.meta) ||
|
|
118
|
+
typeof candidate.bytesUploaded !== "number" ||
|
|
119
|
+
!Number.isFinite(candidate.bytesUploaded) ||
|
|
120
|
+
(candidate.lastCommittedIndex !== undefined &&
|
|
121
|
+
typeof candidate.lastCommittedIndex !== "number")
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Invalid resumable session in cleanup state for recording ${recordingId}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
recordingId,
|
|
130
|
+
generationId,
|
|
131
|
+
ownerGenerationId:
|
|
132
|
+
ownerGenerationId === undefined ? null : ownerGenerationId,
|
|
133
|
+
claimId: claimId === undefined ? null : claimId,
|
|
134
|
+
session: candidate as unknown as StoredResumableSession,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
75
138
|
function normalizeVideoMimeType(value: unknown): string | null {
|
|
76
139
|
if (typeof value !== "string") return null;
|
|
77
140
|
const mimeType = value.split(";")[0]?.trim().toLowerCase();
|
|
@@ -217,6 +280,25 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
217
280
|
const nextGenerationId = useGenerationFence ? randomUUID() : null;
|
|
218
281
|
const uploadStateKey = `recording-upload-${recordingId}`;
|
|
219
282
|
const uploadStateSnapshot = await readAppState(uploadStateKey);
|
|
283
|
+
const cleanupStateKey = `recording-resumable-cleanup-${recordingId}`;
|
|
284
|
+
const cleanupStateSnapshot = await readAppState(cleanupStateKey);
|
|
285
|
+
const parsedCleanup = parsePendingResumableCleanup(
|
|
286
|
+
cleanupStateSnapshot,
|
|
287
|
+
recordingId,
|
|
288
|
+
);
|
|
289
|
+
const pendingCleanup =
|
|
290
|
+
parsedCleanup &&
|
|
291
|
+
(parsedCleanup.ownerGenerationId === null
|
|
292
|
+
? !useGenerationFence
|
|
293
|
+
: parsedCleanup.ownerGenerationId === existingGenerationId)
|
|
294
|
+
? parsedCleanup
|
|
295
|
+
: null;
|
|
296
|
+
const discardedGenerationId = pendingCleanup
|
|
297
|
+
? pendingCleanup.generationId
|
|
298
|
+
: existingGenerationId;
|
|
299
|
+
const discardedResumableSession =
|
|
300
|
+
pendingCleanup?.session ??
|
|
301
|
+
(await getResumableSession(recordingId, existingGenerationId));
|
|
220
302
|
const reset = await db
|
|
221
303
|
.update(schema.recordings)
|
|
222
304
|
.set({
|
|
@@ -251,16 +333,63 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
251
333
|
};
|
|
252
334
|
}
|
|
253
335
|
|
|
336
|
+
let cleanupClaim: PendingResumableCleanup | null = null;
|
|
337
|
+
if (discardedResumableSession) {
|
|
338
|
+
// A cleanup claim belongs to the generation that won this fence. A
|
|
339
|
+
// losing reset must never resurrect a claim after the winner releases
|
|
340
|
+
// it, and a later winner must be able to transfer an unfinished claim.
|
|
341
|
+
const candidate: PendingResumableCleanup = {
|
|
342
|
+
recordingId,
|
|
343
|
+
generationId: discardedGenerationId,
|
|
344
|
+
ownerGenerationId: nextGenerationId,
|
|
345
|
+
claimId: randomUUID(),
|
|
346
|
+
session: discardedResumableSession,
|
|
347
|
+
};
|
|
348
|
+
const claimed = await compareAndSetAppState(
|
|
349
|
+
cleanupStateKey,
|
|
350
|
+
cleanupStateSnapshot,
|
|
351
|
+
candidate as unknown as Record<string, unknown>,
|
|
352
|
+
);
|
|
353
|
+
if (claimed) cleanupClaim = candidate;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (discardedResumableSession) {
|
|
357
|
+
if (cleanupClaim) {
|
|
358
|
+
const cleaned = await abortResumableUploadSession(
|
|
359
|
+
discardedResumableSession,
|
|
360
|
+
{ label: `reset-${recordingId}` },
|
|
361
|
+
);
|
|
362
|
+
if (!cleaned) {
|
|
363
|
+
setResponseStatus(event, 502);
|
|
364
|
+
return {
|
|
365
|
+
error:
|
|
366
|
+
"The previous recording upload could not be cleaned up. Retry the upload restart.",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const released = await compareAndSetAppState(
|
|
370
|
+
cleanupStateKey,
|
|
371
|
+
cleanupClaim as unknown as Record<string, unknown>,
|
|
372
|
+
null,
|
|
373
|
+
);
|
|
374
|
+
if (!released) {
|
|
375
|
+
console.warn(
|
|
376
|
+
`[reset-chunks-${recordingId}] cleanup claim changed while releasing it`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
254
381
|
const cleared = await deleteRecordingChunks(
|
|
255
382
|
ownerEmail,
|
|
256
383
|
recordingId,
|
|
257
|
-
|
|
384
|
+
discardedGenerationId,
|
|
258
385
|
);
|
|
259
386
|
// Clear any stale resumable session so a buffered retry does not
|
|
260
387
|
// accidentally route through handleResumableChunk with stale offsets.
|
|
261
|
-
|
|
262
|
-
()
|
|
263
|
-
|
|
388
|
+
if (!discardedResumableSession || cleanupClaim) {
|
|
389
|
+
await deleteResumableSession(recordingId, discardedGenerationId).catch(
|
|
390
|
+
() => {},
|
|
391
|
+
);
|
|
392
|
+
}
|
|
264
393
|
|
|
265
394
|
let uploadMode: UploadMode = "buffered";
|
|
266
395
|
let compensateStartedSession: (() => Promise<void>) | null = null;
|