@agent-native/core 0.81.2 → 0.82.0

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 (81) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +25 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/frame.ts +55 -0
  5. package/corpus/core/src/file-upload/builder.ts +199 -37
  6. package/corpus/core/src/file-upload/index.ts +2 -0
  7. package/corpus/core/src/file-upload/types.ts +38 -0
  8. package/corpus/core/src/mcp/build-server.ts +30 -4
  9. package/corpus/core/src/mcp/embed-app.ts +33 -18
  10. package/corpus/templates/clips/actions/create-recording.ts +44 -0
  11. package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
  12. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
  13. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
  14. package/corpus/templates/clips/app/routes/record.tsx +12 -1
  15. package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
  16. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
  17. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
  18. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
  19. package/corpus/templates/clips/shared/recording-core.ts +5 -0
  20. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +63 -16
  21. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
  22. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
  23. package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
  24. package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -1
  26. package/corpus/templates/design/app/components/design/LayersPanel.tsx +11 -2
  27. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -16
  28. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +21 -14
  29. package/corpus/templates/design/app/pages/DesignEditor.tsx +557 -211
  30. package/corpus/templates/design/changelog/2026-06-30-design-studio-canvas-layers-can-be-copied-pasted-and-reparen.md +6 -0
  31. package/corpus/templates/design/e2e/global-setup.ts +3 -1
  32. package/corpus/templates/design/e2e/helpers.ts +4 -1
  33. package/corpus/templates/design/playwright.config.ts +4 -1
  34. package/corpus/templates/plan/actions/consume-plan-feedback.ts +1 -9
  35. package/corpus/templates/plan/actions/convert-visual-plan-to-prototype.ts +1 -1
  36. package/corpus/templates/plan/actions/create-plan-design.ts +1 -1
  37. package/corpus/templates/plan/actions/create-prototype-plan.ts +1 -1
  38. package/corpus/templates/plan/actions/create-ui-plan.ts +1 -1
  39. package/corpus/templates/plan/actions/create-visual-plan.ts +36 -19
  40. package/corpus/templates/plan/actions/create-visual-questions.ts +1 -1
  41. package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
  42. package/corpus/templates/plan/actions/delete-plan-comment.ts +1 -9
  43. package/corpus/templates/plan/actions/delete-visual-plan.ts +1 -9
  44. package/corpus/templates/plan/actions/get-plan-feedback.ts +1 -9
  45. package/corpus/templates/plan/actions/get-visual-plan.ts +1 -9
  46. package/corpus/templates/plan/actions/import-visual-plan-source.ts +1 -1
  47. package/corpus/templates/plan/actions/list-visual-plans.ts +1 -9
  48. package/corpus/templates/plan/actions/reply-to-plan-comment.ts +1 -9
  49. package/corpus/templates/plan/actions/resolve-plan-comment.ts +1 -9
  50. package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
  51. package/corpus/templates/plan/actions/visual-answer.ts +1 -1
  52. package/corpus/templates/plan/app/components/layout/Layout.tsx +15 -0
  53. package/corpus/templates/plan/app/entry.client.tsx +10 -0
  54. package/corpus/templates/plan/app/global.css +28 -0
  55. package/corpus/templates/plan/shared/plan-content.ts +10 -6
  56. package/dist/client/frame.d.ts.map +1 -1
  57. package/dist/client/frame.js +59 -0
  58. package/dist/client/frame.js.map +1 -1
  59. package/dist/collab/routes.d.ts +1 -1
  60. package/dist/file-upload/builder.d.ts.map +1 -1
  61. package/dist/file-upload/builder.js +137 -25
  62. package/dist/file-upload/builder.js.map +1 -1
  63. package/dist/file-upload/index.d.ts +1 -1
  64. package/dist/file-upload/index.d.ts.map +1 -1
  65. package/dist/file-upload/index.js.map +1 -1
  66. package/dist/file-upload/types.d.ts +26 -0
  67. package/dist/file-upload/types.d.ts.map +1 -1
  68. package/dist/file-upload/types.js.map +1 -1
  69. package/dist/mcp/build-server.d.ts.map +1 -1
  70. package/dist/mcp/build-server.js +31 -4
  71. package/dist/mcp/build-server.js.map +1 -1
  72. package/dist/mcp/embed-app.d.ts.map +1 -1
  73. package/dist/mcp/embed-app.js +33 -18
  74. package/dist/mcp/embed-app.js.map +1 -1
  75. package/dist/notifications/routes.d.ts +3 -3
  76. package/dist/observability/routes.d.ts +3 -3
  77. package/dist/progress/routes.d.ts +1 -1
  78. package/dist/resources/handlers.d.ts +3 -3
  79. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  80. package/dist/server/transcribe-voice.d.ts +1 -1
  81. package/package.json +1 -1
@@ -11,6 +11,9 @@
11
11
 
12
12
  import { defineAction } from "@agent-native/core";
13
13
  import { writeAppState } from "@agent-native/core/application-state";
14
+ import { getActiveFileUploadProviderForRequest } from "@agent-native/core/file-upload";
15
+ import type { UploadMode } from "@shared/recording-core.js";
16
+ import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
14
17
 
15
18
  import { getDb, schema } from "../server/db/index.js";
16
19
  import {
@@ -19,6 +22,7 @@ import {
19
22
  requireOrganizationAccess,
20
23
  stringifySpaceIds,
21
24
  } from "../server/lib/recordings.js";
25
+ import { setResumableSession } from "../server/lib/resumable-session.js";
22
26
  import { createRecordingSchema } from "./lib/create-recording-schema.js";
23
27
  import { DEFAULT_RECORDING_TITLE } from "./lib/title-source.js";
24
28
 
@@ -76,6 +80,45 @@ export default defineAction({
76
80
 
77
81
  console.log(`Created recording "${title}" (${id})`);
78
82
 
83
+ // Initialize a resumable upload session so chunks are streamed to the
84
+ // provider during recording (no post-stop assembly). Falls back gracefully
85
+ // to the SQL chunk path when no provider supports resumable uploads or the
86
+ // init fails.
87
+ let uploadMode: UploadMode = "buffered";
88
+ const uploadProvider = await getActiveFileUploadProviderForRequest();
89
+ if (args.requestStreaming && uploadProvider?.resumable) {
90
+ try {
91
+ const recordingMimeType =
92
+ args.mimeType?.split(";")[0]?.trim() || "video/webm";
93
+ const ext = /mp4|quicktime/i.test(recordingMimeType) ? "mp4" : "webm";
94
+ const filename = `${id}.${ext}`;
95
+ console.log(
96
+ `[create-recording] starting resumable session: provider=${uploadProvider.id} mimeType=${recordingMimeType}`,
97
+ );
98
+ const session = await uploadProvider.resumable.startSession(
99
+ filename,
100
+ recordingMimeType,
101
+ MAX_UPLOAD_BYTES,
102
+ );
103
+ await setResumableSession(id, {
104
+ providerId: uploadProvider.id,
105
+ sessionId: session.sessionId,
106
+ meta: session.meta,
107
+ bytesUploaded: 0,
108
+ lastCommittedIndex: -1,
109
+ });
110
+ uploadMode = "streaming";
111
+ console.log(
112
+ `[create-recording] resumable session ready for ${id}: provider=${uploadProvider.id}`,
113
+ );
114
+ } catch (err) {
115
+ console.warn(
116
+ `[create-recording] resumable session init failed, falling back to buffered:`,
117
+ err instanceof Error ? err.message : String(err),
118
+ );
119
+ }
120
+ }
121
+
79
122
  return {
80
123
  id,
81
124
  organizationId,
@@ -84,6 +127,7 @@ export default defineAction({
84
127
  abortUrl: `/api/uploads/${id}/abort`,
85
128
  // Frontend substitutes {index}/{total}/{isFinal}
86
129
  uploadChunkUrlTemplate: `/api/uploads/${id}/chunk?index={index}&total={total}&isFinal={isFinal}`,
130
+ uploadMode,
87
131
  };
88
132
  },
89
133
  });
@@ -15,7 +15,10 @@ import {
15
15
  deleteAppState,
16
16
  } from "@agent-native/core/application-state";
17
17
  import { emit } from "@agent-native/core/event-bus";
18
- import { uploadFile } from "@agent-native/core/file-upload";
18
+ import {
19
+ getActiveFileUploadProvider,
20
+ uploadFile,
21
+ } from "@agent-native/core/file-upload";
19
22
  import { captureRouteError } from "@agent-native/core/server";
20
23
  import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
21
24
  import { and, eq } from "drizzle-orm";
@@ -32,6 +35,10 @@ import {
32
35
  getCurrentOwnerEmail,
33
36
  ownerEmailMatches,
34
37
  } from "../server/lib/recordings.js";
38
+ import {
39
+ deleteResumableSession,
40
+ getResumableSession,
41
+ } from "../server/lib/resumable-session.js";
35
42
  import {
36
43
  requiresConfiguredVideoStorage,
37
44
  STORAGE_SETUP_REQUIRED_REASON,
@@ -96,6 +103,120 @@ const cliBoolean = z.preprocess((value) => {
96
103
  return value;
97
104
  }, z.boolean());
98
105
 
106
+ // Flip recording to 'ready', seed transcript row, fire background transcript,
107
+ // emit clip.created. Used by both the resumable and buffered upload paths.
108
+ async function markRecordingReady(params: {
109
+ id: string;
110
+ ownerEmail: string;
111
+ videoUrl: string;
112
+ videoSizeBytes: number;
113
+ videoFormat: "webm" | "mp4";
114
+ finalDurationMs: number;
115
+ finalWidth: number;
116
+ finalHeight: number;
117
+ finalHasAudio: boolean;
118
+ finalHasCamera: boolean;
119
+ existingTitle: string;
120
+ }) {
121
+ const {
122
+ id,
123
+ ownerEmail,
124
+ videoUrl,
125
+ videoSizeBytes,
126
+ videoFormat,
127
+ finalDurationMs,
128
+ finalWidth,
129
+ finalHeight,
130
+ finalHasAudio,
131
+ finalHasCamera,
132
+ existingTitle,
133
+ } = params;
134
+ const db = getDb();
135
+ const now = new Date().toISOString();
136
+
137
+ await db
138
+ .update(schema.recordings)
139
+ .set({
140
+ status: "ready",
141
+ videoUrl,
142
+ videoFormat,
143
+ videoSizeBytes,
144
+ durationMs: finalDurationMs,
145
+ width: finalWidth,
146
+ height: finalHeight,
147
+ hasAudio: finalHasAudio,
148
+ hasCamera: finalHasCamera,
149
+ failureReason: null,
150
+ uploadProgress: 100,
151
+ updatedAt: now,
152
+ })
153
+ .where(
154
+ and(
155
+ eq(schema.recordings.id, id),
156
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
157
+ ),
158
+ );
159
+
160
+ const [existingTranscript] = await db
161
+ .select({ recordingId: schema.recordingTranscripts.recordingId })
162
+ .from(schema.recordingTranscripts)
163
+ .where(eq(schema.recordingTranscripts.recordingId, id));
164
+ if (!existingTranscript) {
165
+ await db.insert(schema.recordingTranscripts).values({
166
+ recordingId: id,
167
+ ownerEmail,
168
+ status: "pending",
169
+ createdAt: now,
170
+ updatedAt: now,
171
+ });
172
+ }
173
+
174
+ await writeAppState(`recording-upload-${id}`, {
175
+ recordingId: id,
176
+ status: "ready",
177
+ progress: 100,
178
+ videoUrl,
179
+ finishedAt: now,
180
+ });
181
+ await writeAppState("refresh-signal", { ts: Date.now() });
182
+
183
+ // Kick off transcription in the background — fire-and-forget so the chunk
184
+ // endpoint gets a quick response. The request context (user email via
185
+ // AsyncLocalStorage) carries through to async continuations.
186
+ void Promise.resolve(
187
+ requestTranscript.run({ recordingId: id, force: true }),
188
+ ).catch((err: unknown) => {
189
+ console.error("[finalize] background transcript failed", {
190
+ id,
191
+ error: err instanceof Error ? err.message : String(err),
192
+ });
193
+ });
194
+
195
+ try {
196
+ emit(
197
+ "clip.created",
198
+ {
199
+ clipId: id,
200
+ title: existingTitle,
201
+ createdBy: ownerEmail,
202
+ duration: finalDurationMs,
203
+ url: videoUrl,
204
+ },
205
+ { owner: ownerEmail },
206
+ );
207
+ } catch (err) {
208
+ console.warn("[finalize] clip.created emit failed:", err);
209
+ }
210
+
211
+ return {
212
+ id,
213
+ status: "ready" as const,
214
+ videoUrl,
215
+ videoSizeBytes,
216
+ durationMs: finalDurationMs,
217
+ };
218
+ }
219
+
99
220
  export default defineAction({
100
221
  description:
101
222
  "Assemble recorded chunks into a final video blob, upload it to the configured storage provider, update the recording row (videoUrl, durationMs, width/height/hasAudio/hasCamera), flip status to 'ready', and trigger the agent to produce a title, summary, transcript, and chapters in the background.",
@@ -154,6 +275,21 @@ export default defineAction({
154
275
  throw new Error(`Recording not found: ${id}`);
155
276
  }
156
277
 
278
+ // Idempotency guard: finalize can be re-invoked when a client retries the
279
+ // final chunk after a lost response. If already 'ready' return the existing
280
+ // result instead of re-running the complete/assembly path (session and
281
+ // chunks are gone by then).
282
+ if (existing.status === "ready" && existing.videoUrl) {
283
+ debugLog("[finalize] already finalized, returning existing", { id });
284
+ return {
285
+ id,
286
+ status: "ready" as const,
287
+ videoUrl: existing.videoUrl,
288
+ videoSizeBytes: existing.videoSizeBytes ?? 0,
289
+ durationMs: existing.durationMs ?? 0,
290
+ };
291
+ }
292
+
157
293
  const uploadStateRaw = await readAppState(`recording-upload-${id}`);
158
294
  const uploadState =
159
295
  uploadStateRaw && typeof uploadStateRaw === "object"
@@ -190,6 +326,62 @@ export default defineAction({
190
326
  ? args.hasCamera
191
327
  : (stateBoolean(uploadState, "hasCamera") ?? existing.hasCamera);
192
328
 
329
+ const readyParams = {
330
+ id,
331
+ ownerEmail,
332
+ videoFormat,
333
+ finalDurationMs,
334
+ finalWidth,
335
+ finalHeight,
336
+ finalHasAudio,
337
+ finalHasCamera,
338
+ existingTitle: existing.title,
339
+ };
340
+
341
+ // Resumable path: create-recording initialized a session and chunk.post.ts
342
+ // forwarded all chunks to the provider. Complete the session to get the CDN URL.
343
+ const resumableSession = await getResumableSession(id);
344
+ if (resumableSession) {
345
+ debugLog("[finalize] resumable session found, completing upload", {
346
+ id,
347
+ providerId: resumableSession.providerId,
348
+ });
349
+ try {
350
+ const uploadProvider = getActiveFileUploadProvider();
351
+ if (!uploadProvider?.resumable) {
352
+ throw new Error("No resumable upload provider configured");
353
+ }
354
+ const videoUrl = await uploadProvider.resumable.completeSession(
355
+ {
356
+ sessionId: resumableSession.sessionId,
357
+ meta: resumableSession.meta,
358
+ },
359
+ typeof resumableSession.meta.filename === "string"
360
+ ? resumableSession.meta.filename
361
+ : "",
362
+ );
363
+ debugLog("[finalize] resumable upload completed", { id, videoUrl });
364
+ const result = await markRecordingReady({
365
+ ...readyParams,
366
+ videoUrl,
367
+ videoSizeBytes: resumableSession.bytesUploaded,
368
+ });
369
+ // Delete only after durable state is written — so a retry before
370
+ // this point can still find the session and re-enter this path.
371
+ deleteResumableSession(id).catch((err) =>
372
+ console.warn("[finalize] failed to delete resumable session:", err),
373
+ );
374
+ return result;
375
+ } catch (err) {
376
+ console.error("[finalize] resumable complete failed:", err);
377
+ throw new Error(
378
+ `Upload completion failed: ${err instanceof Error ? err.message : String(err)}`,
379
+ );
380
+ }
381
+ }
382
+
383
+ // Buffered path — assemble chunks from application_state, then upload.
384
+
193
385
  // The recorder stashes compression metadata at
194
386
  // `recording-compression-{id}` when its browser-side ffmpeg.wasm
195
387
  // pass ran to bring the assembled blob under Builder.io's 100 MB
@@ -514,99 +706,17 @@ export default defineAction({
514
706
  }
515
707
  throw err;
516
708
  }
517
- const videoUrl = upload.url;
518
-
519
- // Update the recording row with final metadata and flip to 'ready'.
520
- await db
521
- .update(schema.recordings)
522
- .set({
523
- status: "ready",
524
- videoUrl,
525
- videoFormat,
526
- videoSizeBytes: assembled.byteLength,
527
- durationMs: finalDurationMs,
528
- width: finalWidth,
529
- height: finalHeight,
530
- hasAudio: finalHasAudio,
531
- hasCamera: finalHasCamera,
532
- failureReason: null,
533
- uploadProgress: 100,
534
- updatedAt: new Date().toISOString(),
535
- })
536
- .where(eq(schema.recordings.id, id));
537
-
538
- // Seed a pending transcript row so the agent background task has a place
539
- // to write results.
540
- const [existingTranscript] = await db
541
- .select({ recordingId: schema.recordingTranscripts.recordingId })
542
- .from(schema.recordingTranscripts)
543
- .where(eq(schema.recordingTranscripts.recordingId, id));
544
- if (!existingTranscript) {
545
- await db.insert(schema.recordingTranscripts).values({
546
- recordingId: id,
547
- ownerEmail,
548
- status: "pending",
549
- createdAt: new Date().toISOString(),
550
- updatedAt: new Date().toISOString(),
551
- });
552
- }
553
-
554
- await writeAppState(`recording-upload-${id}`, {
555
- recordingId: id,
556
- status: "ready",
557
- progress: 100,
558
- videoUrl,
559
- finishedAt: new Date().toISOString(),
560
- });
561
-
562
- await writeAppState("refresh-signal", { ts: Date.now() });
563
-
564
- // Kick off transcription in the background. Native web/macOS speech rows
565
- // are preserved first; cloud transcription only fills gaps/refines when
566
- // needed. The chunk endpoint already awaits this finalize call, so we
567
- // fire-and-forget — the request context (user email via AsyncLocalStorage)
568
- // carries through to async continuations started before this run()
569
- // returns. Without this the transcript row stays in `pending` forever and
570
- // the UI shows an infinite "Transcribing…" spinner.
571
- void Promise.resolve(
572
- requestTranscript.run({ recordingId: id, force: true }),
573
- ).catch((err: unknown) => {
574
- console.error("[finalize] background transcript failed", {
575
- id,
576
- error: err instanceof Error ? err.message : String(err),
577
- });
578
- });
579
-
580
- // Emit clip.created event — best-effort, never block the main flow.
581
- try {
582
- emit(
583
- "clip.created",
584
- {
585
- clipId: id,
586
- title: existing.title,
587
- createdBy: ownerEmail,
588
- duration: finalDurationMs,
589
- url: videoUrl,
590
- },
591
- { owner: ownerEmail },
592
- );
593
- } catch (err) {
594
- console.warn("[finalize] clip.created emit failed:", err);
595
- }
596
709
 
597
710
  debugLog("[finalize] done", {
598
711
  id,
599
- videoUrl,
712
+ videoUrl: upload.url,
600
713
  bytes: assembled.byteLength,
601
714
  });
602
-
603
- return {
604
- id,
605
- status: "ready" as const,
606
- videoUrl,
715
+ return markRecordingReady({
716
+ ...readyParams,
717
+ videoUrl: upload.url,
607
718
  videoSizeBytes: assembled.byteLength,
608
- durationMs: finalDurationMs,
609
- };
719
+ });
610
720
  } finally {
611
721
  // Unconditional chunk scratch-space cleanup. Runs on success AND on
612
722
  // error — a throw during uploadFile / drizzle update / anything else
@@ -64,4 +64,16 @@ export const createRecordingSchema = z.object({
64
64
  .enum(["private", "org", "public"])
65
65
  .optional()
66
66
  .describe("Initial share visibility for the recording"),
67
+ mimeType: z
68
+ .string()
69
+ .optional()
70
+ .describe(
71
+ "MIME type the browser will record (e.g. video/webm, video/mp4). Used to initialize the resumable session with the correct content type.",
72
+ ),
73
+ requestStreaming: z
74
+ .boolean()
75
+ .optional()
76
+ .describe(
77
+ "Opt in to the resumable streaming upload path. Only send this when the caller can produce 256 KiB-aligned chunks (GCS requirement). Defaults to false — callers that omit this always get the buffered path.",
78
+ ),
67
79
  });
@@ -11,6 +11,7 @@ import {
11
11
  chunkUploadUrl,
12
12
  pickMimeType,
13
13
  pickMimeTypeCandidates,
14
+ type UploadMode,
14
15
  } from "@shared/recording-core";
15
16
 
16
17
  import {
@@ -32,7 +33,7 @@ import {
32
33
 
33
34
  // Re-exported for existing callers; the canonical impls live in
34
35
  // @shared/recording-core and are shared with the Chrome extension recorder.
35
- export { pickMimeType, pickMimeTypeCandidates };
36
+ export { pickMimeType, pickMimeTypeCandidates, canUseTimeslicedRecorderChunks };
36
37
 
37
38
  export type RecordingMode = "screen" | "camera" | "screen+camera";
38
39
  export type DisplaySurface = "monitor" | "window" | "browser";
@@ -95,6 +96,13 @@ export interface RecorderEngineOptions {
95
96
  uploadUrl?: string;
96
97
  /** Abort URL. Default `/api/uploads/:id/abort`. */
97
98
  abortUrl?: string;
99
+ /**
100
+ * Upload strategy returned by create-recording.
101
+ * `"streaming"` — server has a resumable session; engine flushes aligned
102
+ * chunks during recording. `"buffered"` — blob assembled after stop() and
103
+ * uploaded in slices via the SQL chunk path.
104
+ */
105
+ uploadMode?: UploadMode;
98
106
  /** Fired whenever the state machine transitions. */
99
107
  onState?: (state: RecorderState, detail?: Record<string, unknown>) => void;
100
108
  /** Fired on each uploaded chunk (for progress UI). */
@@ -183,6 +191,11 @@ interface CompressionUploadMeta {
183
191
  }
184
192
 
185
193
  const DEFAULT_CHUNK_MS = 1000;
194
+ // GCS resumable uploads require every non-final chunk to be a multiple of
195
+ // 256 KiB. MediaRecorder emits arbitrary blob sizes, so on the streaming path
196
+ // we buffer raw blobs and only PUT aligned slices. ~4 MiB per streamed chunk.
197
+ const GCS_CHUNK_ALIGN_BYTES = 256 * 1024;
198
+ const STREAM_CHUNK_BYTES = 15 * GCS_CHUNK_ALIGN_BYTES; // 3.75 MiB
186
199
  const CHUNK_UPLOAD_MAX_ATTEMPTS = 3;
187
200
  const RETRYABLE_CHUNK_UPLOAD_STATUSES = new Set([
188
201
  408, 425, 429, 500, 502, 503, 504,
@@ -430,7 +443,15 @@ export class RecorderEngine {
430
443
  * fetch quietly complete and the recording finalise server-side.
431
444
  */
432
445
  private uploadAbort: AbortController | null = null;
433
- private streamChunksDuringRecording = false;
446
+ private uploadMode: UploadMode = "buffered";
447
+ /**
448
+ * Streaming-path buffer. MediaRecorder blobs accumulate here until at least
449
+ * STREAM_CHUNK_BYTES is available, then a 256 KiB-aligned slice is PUT to API
450
+ * as a non-final chunk. The unaligned remainder is held and uploaded as the
451
+ * final chunk on stop().
452
+ */
453
+ private pendingStreamBlobs: Blob[] = [];
454
+ private pendingStreamBytes = 0;
434
455
  /**
435
456
  * One-shot guards so a camera/mic disconnect warning fires at most once even
436
457
  * when a device exposes multiple tracks that each emit `ended`.
@@ -806,10 +827,12 @@ export class RecorderEngine {
806
827
  recordingId: string;
807
828
  uploadUrl: string;
808
829
  abortUrl: string;
830
+ uploadMode?: UploadMode;
809
831
  }): void {
810
832
  this.opts.recordingId = target.recordingId;
811
833
  this.opts.uploadUrl = target.uploadUrl;
812
834
  this.opts.abortUrl = target.abortUrl;
835
+ this.opts.uploadMode = target.uploadMode ?? "buffered";
813
836
  }
814
837
 
815
838
  // -------------------------------------------------------------------------
@@ -884,7 +907,9 @@ export class RecorderEngine {
884
907
  this.totalRecordedBytes = 0;
885
908
  this.lastFinalizeMeta = null;
886
909
  this.uploadAbort = new AbortController();
887
- this.streamChunksDuringRecording = false;
910
+ this.uploadMode = this.opts.uploadMode ?? "buffered";
911
+ this.pendingStreamBlobs = [];
912
+ this.pendingStreamBytes = 0;
888
913
  this.cameraDisconnectNotified = false;
889
914
  this.micDisconnectNotified = false;
890
915
  const useTimeslicedLocalChunks = canUseTimeslicedRecorderChunks(
@@ -901,9 +926,10 @@ export class RecorderEngine {
901
926
  // whether this recording needs compression.
902
927
  this.localChunks.push(blob);
903
928
  this.totalRecordedBytes += blob.size;
904
- if (this.streamChunksDuringRecording) {
905
- const index = this.chunkIndex++;
906
- this.queueChunk(blob, index, /* isFinal */ false);
929
+ if (this.uploadMode === "streaming") {
930
+ this.pendingStreamBlobs.push(blob);
931
+ this.pendingStreamBytes += blob.size;
932
+ this.flushAlignedStreamChunks();
907
933
  }
908
934
  });
909
935
 
@@ -1071,12 +1097,13 @@ export class RecorderEngine {
1071
1097
  try {
1072
1098
  if (
1073
1099
  COMPRESSION_ENABLED &&
1074
- this.totalRecordedBytes > COMPRESS_THRESHOLD_BYTES
1100
+ this.totalRecordedBytes > COMPRESS_THRESHOLD_BYTES &&
1101
+ this.opts.uploadMode !== "streaming"
1075
1102
  ) {
1076
1103
  // Compress before the first server upload so large recordings don't
1077
1104
  // stage their uncompressed source in SQL.
1078
1105
  result = await this.compressAndReupload(finalizeMeta);
1079
- } else if (!this.streamChunksDuringRecording) {
1106
+ } else if (this.uploadMode !== "streaming") {
1080
1107
  this.transition("uploading", { progress: 0 });
1081
1108
  const assembled = new Blob(this.localChunks, { type: this.mimeType });
1082
1109
  result = await this.uploadBlobInSlices(
@@ -1086,26 +1113,28 @@ export class RecorderEngine {
1086
1113
  this.uploadAbort?.signal,
1087
1114
  );
1088
1115
  } else {
1089
- // Send a 0-byte isFinal sentinel the actual final-chunk bytes
1090
- // were already uploaded by the start()-time listener as a
1091
- // regular (non-final) chunk. Mirroring the auto-stop path so
1092
- // both branches share one code shape.
1116
+ // Streaming path: all 256 KiB-aligned chunks were queued during
1117
+ // recording. Whatever bytes remain become the
1118
+ // final chunk. If the recording happened to end exactly
1119
+ // on a boundary the remainder is empty, which the server treats as a
1120
+ // close sentinel.
1093
1121
  this.transition("uploading", { progress: 100 });
1094
- result = await this.uploadChunk(
1095
- new Blob([], { type: this.mimeType }),
1096
- this.chunkIndex++,
1097
- {
1098
- isFinal: true,
1099
- total: this.chunkIndex,
1100
- mimeType: this.mimeType,
1101
- durationMs,
1102
- width: dimensions.width,
1103
- height: dimensions.height,
1104
- hasAudio,
1105
- hasCamera,
1106
- signal: this.uploadAbort?.signal,
1107
- },
1108
- );
1122
+ const remainder = new Blob(this.pendingStreamBlobs, {
1123
+ type: this.mimeType,
1124
+ });
1125
+ this.pendingStreamBlobs = [];
1126
+ this.pendingStreamBytes = 0;
1127
+ result = await this.uploadChunk(remainder, this.chunkIndex++, {
1128
+ isFinal: true,
1129
+ total: this.chunkIndex,
1130
+ mimeType: this.mimeType,
1131
+ durationMs,
1132
+ width: dimensions.width,
1133
+ height: dimensions.height,
1134
+ hasAudio,
1135
+ hasCamera,
1136
+ signal: this.uploadAbort?.signal,
1137
+ });
1109
1138
  }
1110
1139
  this.transition("complete");
1111
1140
  completed = true;
@@ -1523,6 +1552,30 @@ export class RecorderEngine {
1523
1552
  }
1524
1553
  }
1525
1554
 
1555
+ /**
1556
+ * Drain the streaming buffer in 256 KiB-aligned chunks.
1557
+ * GCS rejects non-final resumable chunks that are not a multiple of 256 KiB,
1558
+ * so we slice on STREAM_CHUNK_BYTES boundaries and carry the remainder. The
1559
+ * leftover is uploaded as the final chunk on stop().
1560
+ */
1561
+ private flushAlignedStreamChunks(): void {
1562
+ while (this.pendingStreamBytes >= STREAM_CHUNK_BYTES) {
1563
+ const combined = new Blob(this.pendingStreamBlobs, {
1564
+ type: this.mimeType,
1565
+ });
1566
+ const head = combined.slice(0, STREAM_CHUNK_BYTES, this.mimeType);
1567
+ const tail = combined.slice(
1568
+ STREAM_CHUNK_BYTES,
1569
+ combined.size,
1570
+ this.mimeType,
1571
+ );
1572
+ this.pendingStreamBlobs = tail.size > 0 ? [tail] : [];
1573
+ this.pendingStreamBytes = tail.size;
1574
+ const index = this.chunkIndex++;
1575
+ this.queueChunk(head, index, /* isFinal */ false);
1576
+ }
1577
+ }
1578
+
1526
1579
  private queueChunk(blob: Blob, index: number, isFinal: boolean): void {
1527
1580
  this.chunkQueue = this.chunkQueue.then(async () => {
1528
1581
  if (this.uploadFailure) return;
@@ -1660,8 +1713,10 @@ export class RecorderEngine {
1660
1713
  signal: extra.signal,
1661
1714
  });
1662
1715
  } catch (err) {
1716
+ // The final chunk is safe to retry on a network error: finalize is
1717
+ // idempotent server-side (a recording already 'ready' returns its
1718
+ // existing result), so a lost response won't double-finalize.
1663
1719
  if (
1664
- extra.isFinal ||
1665
1720
  attempt >= CHUNK_UPLOAD_MAX_ATTEMPTS ||
1666
1721
  (err as { name?: string } | null)?.name === "AbortError"
1667
1722
  ) {
@@ -1673,7 +1728,6 @@ export class RecorderEngine {
1673
1728
 
1674
1729
  if (
1675
1730
  !res.ok &&
1676
- !extra.isFinal &&
1677
1731
  attempt < CHUNK_UPLOAD_MAX_ATTEMPTS &&
1678
1732
  isRetryableChunkUploadStatus(res.status)
1679
1733
  ) {
@@ -7,7 +7,11 @@ import {
7
7
  } from "@agent-native/core/client";
8
8
  import { useLiveTranscription } from "@agent-native/core/client/transcription/use-live-transcription";
9
9
  import type { BrowserDiagnosticsData } from "@shared/browser-diagnostics";
10
- import { chunkUploadUrl } from "@shared/recording-core";
10
+ import {
11
+ chunkUploadUrl,
12
+ pickMimeType,
13
+ type UploadMode,
14
+ } from "@shared/recording-core";
11
15
  import {
12
16
  IconAlertTriangle,
13
17
  IconArrowLeft,
@@ -95,6 +99,7 @@ import { CountdownOverlay } from "@/components/recorder/countdown-overlay";
95
99
  import { PreRecordPanel } from "@/components/recorder/pre-record-panel";
96
100
  import {
97
101
  RecorderEngine,
102
+ canUseTimeslicedRecorderChunks,
98
103
  NO_MIC_DEVICE_ID,
99
104
  type DisplaySurface,
100
105
  type RecorderFinalizeResult,
@@ -567,6 +572,7 @@ interface PendingRecording {
567
572
  id: string;
568
573
  uploadChunkUrl: string;
569
574
  abortUrl: string;
575
+ uploadMode?: UploadMode;
570
576
  }
571
577
 
572
578
  function PreRecordPanelSkeleton() {
@@ -1194,6 +1200,8 @@ export default function RecordRoute() {
1194
1200
  visibility: reportContext ? "org" : "public",
1195
1201
  spaceIds: spaceIdFromUrl ? [spaceIdFromUrl] : undefined,
1196
1202
  folderId: folderIdFromUrl ?? undefined,
1203
+ mimeType: pickMimeType() || undefined,
1204
+ requestStreaming: canUseTimeslicedRecorderChunks(pickMimeType()),
1197
1205
  }),
1198
1206
  },
1199
1207
  );
@@ -1213,10 +1221,12 @@ export default function RecordRoute() {
1213
1221
  id: string;
1214
1222
  uploadChunkUrl: string;
1215
1223
  abortUrl: string;
1224
+ uploadMode?: UploadMode;
1216
1225
  };
1217
1226
  id?: string;
1218
1227
  uploadChunkUrl?: string;
1219
1228
  abortUrl?: string;
1229
+ uploadMode?: UploadMode;
1220
1230
  };
1221
1231
  const info = created.result ?? (created as PendingRecording);
1222
1232
  if (!info?.id) {
@@ -1244,6 +1254,7 @@ export default function RecordRoute() {
1244
1254
  recordingId: info.id,
1245
1255
  uploadUrl: uploadChunkUrl,
1246
1256
  abortUrl,
1257
+ uploadMode: info.uploadMode,
1247
1258
  });
1248
1259
  await saveBugReportContextRef.current(info.id);
1249
1260