@agent-native/core 0.109.0 → 0.109.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/integrations.mdx +87 -253
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/a2a/client.ts +24 -2
  6. package/corpus/core/src/a2a/correlation.ts +50 -0
  7. package/corpus/core/src/a2a/handlers.ts +140 -14
  8. package/corpus/core/src/a2a/index.ts +1 -0
  9. package/corpus/core/src/a2a/invoke.ts +10 -1
  10. package/corpus/core/src/a2a/task-store.ts +146 -6
  11. package/corpus/core/src/a2a/types.ts +16 -0
  12. package/corpus/core/src/action.ts +2 -0
  13. package/corpus/core/src/agent/production-agent.ts +23 -0
  14. package/corpus/core/src/client/blocks/library/diagram.config.ts +10 -1
  15. package/corpus/core/src/client/blocks/library/diagram.tsx +9 -4
  16. package/corpus/core/src/client/use-run-stuck-detection.ts +27 -1
  17. package/corpus/core/src/db/client.ts +55 -0
  18. package/corpus/core/src/deploy/build.ts +63 -3
  19. package/corpus/core/src/observability/traces.ts +172 -19
  20. package/corpus/core/src/scripts/call-agent.ts +53 -2
  21. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +72 -25
  22. package/corpus/core/src/server/agent-chat-plugin.ts +107 -6
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  24. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  25. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  26. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  27. package/corpus/templates/analytics/changelog/2026-07-17-analytics-now-includes-a-default-alert-for-spikes-in-stuck-agent-chats.md +6 -0
  28. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +23 -14
  29. package/corpus/templates/analytics/docs/schemas/tracked-events.md +5 -1
  30. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +3 -3
  31. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +209 -87
  32. package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +28 -5
  33. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +7 -0
  34. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +23 -0
  35. package/corpus/templates/clips/AGENTS.md +10 -0
  36. package/corpus/templates/clips/actions/create-dictation.ts +29 -4
  37. package/corpus/templates/clips/actions/finalize-recording.ts +542 -65
  38. package/corpus/templates/clips/actions/get-recording-player-data.ts +16 -6
  39. package/corpus/templates/clips/actions/set-mobile-capture-state.ts +37 -0
  40. package/corpus/templates/clips/actions/update-dictation.ts +9 -1
  41. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +17 -2
  42. package/corpus/templates/clips/app/components/player/video-player.tsx +14 -2
  43. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +2 -2
  44. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +14 -3
  45. package/corpus/templates/clips/app/routes/record.tsx +3 -3
  46. package/corpus/templates/clips/app/routes/share.$shareId.tsx +19 -5
  47. package/corpus/templates/clips/changelog/2026-07-17-capture-dictation-background-meeting-audio-and-camera-videos.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-clips-now-retries-storage-verification-in-the-background-ins.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-17-delayed-upload-verification-keeps-local-backups-retryable.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-17-fixed-broken-thumbnail-icons-appearing-over-playable-clip-em.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-17-playback-comments-stay-readable-at-faster-playback-speeds.md +6 -0
  52. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +3 -0
  53. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -3
  54. package/corpus/templates/clips/desktop/src/app.tsx +35 -6
  55. package/corpus/templates/clips/desktop/src/lib/processing-backup-recovery.ts +19 -0
  56. package/corpus/templates/clips/desktop/src/lib/recorder.ts +141 -18
  57. package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +28 -7
  58. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -4
  59. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +32 -15
  60. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +59 -18
  61. package/corpus/templates/clips/server/db/schema.ts +9 -1
  62. package/corpus/templates/clips/server/jobs/media-verification.ts +130 -0
  63. package/corpus/templates/clips/server/lib/builder-media-compression.ts +74 -7
  64. package/corpus/templates/clips/server/lib/media-verification-state.ts +55 -0
  65. package/corpus/templates/clips/server/lib/player-thumbnail-url.ts +31 -0
  66. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -3
  67. package/corpus/templates/clips/server/plugins/auth.ts +5 -5
  68. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  69. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +12 -2
  70. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +13 -1
  71. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +340 -0
  72. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +24 -7
  73. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +91 -7
  74. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +22 -1
  75. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +14 -0
  76. package/corpus/templates/clips/shared/finalize-recovery.ts +61 -2
  77. package/corpus/templates/clips/shared/share-meta.ts +1 -1
  78. package/corpus/templates/plan/changelog/2026-07-17-diagrams-can-opt-into-a-polished-clean-rendering-mode-withou.md +6 -0
  79. package/corpus/templates/plan/shared/plan-content.ts +3 -0
  80. package/dist/a2a/client.d.ts +11 -2
  81. package/dist/a2a/client.d.ts.map +1 -1
  82. package/dist/a2a/client.js +16 -3
  83. package/dist/a2a/client.js.map +1 -1
  84. package/dist/a2a/correlation.d.ts +10 -0
  85. package/dist/a2a/correlation.d.ts.map +1 -0
  86. package/dist/a2a/correlation.js +40 -0
  87. package/dist/a2a/correlation.js.map +1 -0
  88. package/dist/a2a/handlers.d.ts.map +1 -1
  89. package/dist/a2a/handlers.js +100 -15
  90. package/dist/a2a/handlers.js.map +1 -1
  91. package/dist/a2a/index.d.ts +1 -1
  92. package/dist/a2a/index.d.ts.map +1 -1
  93. package/dist/a2a/index.js.map +1 -1
  94. package/dist/a2a/invoke.d.ts +4 -1
  95. package/dist/a2a/invoke.d.ts.map +1 -1
  96. package/dist/a2a/invoke.js +3 -0
  97. package/dist/a2a/invoke.js.map +1 -1
  98. package/dist/a2a/task-store.d.ts +12 -1
  99. package/dist/a2a/task-store.d.ts.map +1 -1
  100. package/dist/a2a/task-store.js +96 -6
  101. package/dist/a2a/task-store.js.map +1 -1
  102. package/dist/a2a/types.d.ts +15 -0
  103. package/dist/a2a/types.d.ts.map +1 -1
  104. package/dist/a2a/types.js.map +1 -1
  105. package/dist/action.d.ts +2 -0
  106. package/dist/action.d.ts.map +1 -1
  107. package/dist/action.js.map +1 -1
  108. package/dist/agent/production-agent.d.ts +9 -0
  109. package/dist/agent/production-agent.d.ts.map +1 -1
  110. package/dist/agent/production-agent.js +14 -0
  111. package/dist/agent/production-agent.js.map +1 -1
  112. package/dist/client/blocks/library/diagram.config.d.ts +2 -0
  113. package/dist/client/blocks/library/diagram.config.d.ts.map +1 -1
  114. package/dist/client/blocks/library/diagram.config.js +8 -1
  115. package/dist/client/blocks/library/diagram.config.js.map +1 -1
  116. package/dist/client/blocks/library/diagram.d.ts.map +1 -1
  117. package/dist/client/blocks/library/diagram.js +9 -4
  118. package/dist/client/blocks/library/diagram.js.map +1 -1
  119. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  120. package/dist/client/use-run-stuck-detection.js +25 -1
  121. package/dist/client/use-run-stuck-detection.js.map +1 -1
  122. package/dist/collab/routes.d.ts +1 -1
  123. package/dist/db/client.d.ts +7 -0
  124. package/dist/db/client.d.ts.map +1 -1
  125. package/dist/db/client.js +33 -0
  126. package/dist/db/client.js.map +1 -1
  127. package/dist/deploy/build.d.ts +3 -0
  128. package/dist/deploy/build.d.ts.map +1 -1
  129. package/dist/deploy/build.js +45 -1
  130. package/dist/deploy/build.js.map +1 -1
  131. package/dist/observability/routes.d.ts +1 -1
  132. package/dist/observability/traces.d.ts +9 -0
  133. package/dist/observability/traces.d.ts.map +1 -1
  134. package/dist/observability/traces.js +137 -19
  135. package/dist/observability/traces.js.map +1 -1
  136. package/dist/progress/routes.d.ts +1 -1
  137. package/dist/scripts/call-agent.d.ts.map +1 -1
  138. package/dist/scripts/call-agent.js +35 -3
  139. package/dist/scripts/call-agent.js.map +1 -1
  140. package/dist/server/agent-chat/action-filters-a2a.d.ts +18 -2
  141. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  142. package/dist/server/agent-chat/action-filters-a2a.js +35 -7
  143. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  144. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  145. package/dist/server/agent-chat-plugin.js +93 -6
  146. package/dist/server/agent-chat-plugin.js.map +1 -1
  147. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  148. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  149. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  150. package/docs/content/integrations.mdx +87 -253
  151. package/package.json +1 -1
  152. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  153. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  154. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
@@ -51,7 +51,10 @@ import { invoke } from "@tauri-apps/api/core";
51
51
  import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
52
52
  import { open as openExternal } from "@tauri-apps/plugin-shell";
53
53
 
54
- import { waitForReadyRecordingAfterFinalizeError } from "../../../shared/finalize-recovery";
54
+ import {
55
+ waitForAcceptedRecordingAfterFinalizeError,
56
+ waitForReadyRecordingAfterFinalizeError,
57
+ } from "../../../shared/finalize-recovery";
55
58
  import type { LocalRecordingMode } from "../shared/config";
56
59
  import { createAudioCue, type AudioCue } from "./audio-cue";
57
60
  import { createCameraCompositeStream } from "./camera-composite";
@@ -75,6 +78,7 @@ import {
75
78
  createPauseTransitionQueue,
76
79
  type PauseTransitionQueue,
77
80
  } from "./pause-transition";
81
+ import { reconcileProcessingBackup } from "./processing-backup-recovery";
78
82
  import { buildCaptureTitle, type CaptureTitleResult } from "./recording-title";
79
83
  import { singleFlight } from "./single-flight";
80
84
  import {
@@ -83,6 +87,7 @@ import {
83
87
  type TranscriptionCapture,
84
88
  } from "./transcription-capture";
85
89
  import {
90
+ parseFinalizeReceipt,
86
91
  verifyFinalizeReceipt,
87
92
  type FinalizeReceipt,
88
93
  } from "./upload-verification";
@@ -741,6 +746,7 @@ export async function dismissBrowserRecordingBackup(
741
746
  async function markBrowserRecordingBackupError(
742
747
  recordingId: string,
743
748
  error: string,
749
+ incrementRetryCount = true,
744
750
  ): Promise<void> {
745
751
  const meta = await getBrowserRecordingBackupMeta(recordingId);
746
752
  if (!meta) return;
@@ -748,11 +754,84 @@ async function markBrowserRecordingBackupError(
748
754
  ...meta,
749
755
  lastAttemptAt: new Date().toISOString(),
750
756
  lastError: error,
751
- retryCount: meta.retryCount + 1,
757
+ retryCount: incrementRetryCount ? meta.retryCount + 1 : meta.retryCount,
758
+ });
759
+ }
760
+
761
+ const MEDIA_VERIFICATION_BACKUP_ERROR =
762
+ "The server could not finish verifying this upload. The local copy is still saved; retry to continue.";
763
+
764
+ async function flagBrowserBackupAfterProcessing(recordingId: string) {
765
+ await markBrowserRecordingBackupError(
766
+ recordingId,
767
+ MEDIA_VERIFICATION_BACKUP_ERROR,
768
+ false,
769
+ );
770
+ await emit("clips:pending-uploads-changed").catch(() => {});
771
+ }
772
+
773
+ async function flagNativeBackupAfterProcessing(recordingId: string) {
774
+ await invoke("native_fullscreen_recording_mark_upload_error", {
775
+ recordingId,
776
+ error: MEDIA_VERIFICATION_BACKUP_ERROR,
777
+ });
778
+ }
779
+
780
+ function scheduleBrowserBackupCleanupAfterProcessing(args: {
781
+ serverUrl: string;
782
+ recordingId: string;
783
+ authToken?: string;
784
+ }): void {
785
+ void reconcileProcessingBackup({
786
+ waitForReady: () =>
787
+ waitForReadyRecordingAfterFinalizeError({
788
+ uploadUrl: chunkUrl(args.serverUrl, args.recordingId, 0, false),
789
+ recordingId: args.recordingId,
790
+ authToken: args.authToken,
791
+ preferAuthenticated: true,
792
+ timeoutMs: 12 * 60 * 1000,
793
+ }),
794
+ onReady: () => deleteBrowserRecordingBackup(args.recordingId),
795
+ onUnresolved: () => flagBrowserBackupAfterProcessing(args.recordingId),
796
+ onPollError: (err) => {
797
+ console.warn(
798
+ "[clips-recorder] background backup cleanup check failed:",
799
+ err,
800
+ );
801
+ },
802
+ }).catch((err) => {
803
+ console.warn("[clips-recorder] background backup flag failed:", err);
804
+ });
805
+ }
806
+
807
+ export function scheduleNativeBackupCleanupAfterProcessing(args: {
808
+ serverUrl: string;
809
+ recordingId: string;
810
+ authToken?: string;
811
+ }): void {
812
+ void reconcileProcessingBackup({
813
+ waitForReady: () =>
814
+ waitForReadyRecordingAfterFinalizeError({
815
+ uploadUrl: chunkUrl(args.serverUrl, args.recordingId, 0, false),
816
+ recordingId: args.recordingId,
817
+ authToken: args.authToken,
818
+ preferAuthenticated: true,
819
+ timeoutMs: 12 * 60 * 1000,
820
+ }),
821
+ onReady: () =>
822
+ invoke("native_fullscreen_recording_dismiss_upload", {
823
+ recordingId: args.recordingId,
824
+ }),
825
+ onUnresolved: () => flagNativeBackupAfterProcessing(args.recordingId),
826
+ onPollError: (err) => {
827
+ console.warn("[clips-recorder] native backup cleanup check failed:", err);
828
+ },
829
+ }).catch((err) => {
830
+ console.warn("[clips-recorder] native backup flag failed:", err);
752
831
  });
753
832
  }
754
833
 
755
- async function recoverReadyRecordingAfterFinalizeError({
834
+ async function recoverAcceptedRecordingAfterFinalizeError({
756
835
  serverUrl,
757
836
  recordingId,
758
837
  authToken,
@@ -760,19 +839,27 @@ async function recoverReadyRecordingAfterFinalizeError({
760
839
  serverUrl: string;
761
840
  recordingId: string;
762
841
  authToken?: string;
763
- }): Promise<boolean> {
764
- const recovered = await waitForReadyRecordingAfterFinalizeError({
842
+ }): Promise<"processing" | "ready" | null> {
843
+ const recovered = await waitForAcceptedRecordingAfterFinalizeError({
765
844
  uploadUrl: chunkUrl(serverUrl, recordingId, 0, false),
766
845
  recordingId,
767
846
  authToken,
768
847
  preferAuthenticated: true,
769
848
  });
770
- if (!recovered) return false;
849
+ if (!recovered) return null;
850
+ if (recovered.status === "processing") {
851
+ scheduleBrowserBackupCleanupAfterProcessing({
852
+ serverUrl,
853
+ recordingId,
854
+ authToken,
855
+ });
856
+ return "processing";
857
+ }
771
858
  await markBrowserRecordingBackupError(
772
859
  recordingId,
773
860
  "Upload completed, but its final receipt could not be verified. The local backup was kept.",
774
861
  ).catch(() => {});
775
- return true;
862
+ return "ready";
776
863
  }
777
864
 
778
865
  export async function listBrowserRecordingBackups(): Promise<
@@ -824,7 +911,7 @@ async function postBackupChunk(
824
911
  `Upload retry failed (${res.status}): ${body.slice(0, 200)}`,
825
912
  );
826
913
  }
827
- return body ? (JSON.parse(body) as FinalizeReceipt) : null;
914
+ return parseFinalizeReceipt(body);
828
915
  }
829
916
 
830
917
  async function resetBrowserRecordingBackupUpload(
@@ -951,10 +1038,21 @@ export async function retryBrowserRecordingBackup(input: {
951
1038
  validatedChunks,
952
1039
  input.authToken,
953
1040
  );
954
- verifyFinalizeReceipt(receipt, meta);
1041
+ const receiptStatus = verifyFinalizeReceipt(receipt, meta);
1042
+ if (receiptStatus === "processing") {
1043
+ scheduleBrowserBackupCleanupAfterProcessing({
1044
+ serverUrl: meta.serverUrl,
1045
+ recordingId: meta.recordingId,
1046
+ authToken: input.authToken,
1047
+ });
1048
+ return {
1049
+ recordingId: meta.recordingId,
1050
+ viewUrl: `/r/${meta.recordingId}`,
1051
+ };
1052
+ }
955
1053
  } catch (err) {
956
1054
  if (
957
- await recoverReadyRecordingAfterFinalizeError({
1055
+ await recoverAcceptedRecordingAfterFinalizeError({
958
1056
  serverUrl: meta.serverUrl,
959
1057
  recordingId: meta.recordingId,
960
1058
  authToken: input.authToken,
@@ -1007,10 +1105,21 @@ export async function retryBrowserRecordingBackup(input: {
1007
1105
  new Blob([], { type: meta.mimeType }),
1008
1106
  input.authToken,
1009
1107
  );
1010
- verifyFinalizeReceipt(receipt, meta);
1108
+ const receiptStatus = verifyFinalizeReceipt(receipt, meta);
1109
+ if (receiptStatus === "processing") {
1110
+ scheduleBrowserBackupCleanupAfterProcessing({
1111
+ serverUrl: meta.serverUrl,
1112
+ recordingId: meta.recordingId,
1113
+ authToken: input.authToken,
1114
+ });
1115
+ return {
1116
+ recordingId: meta.recordingId,
1117
+ viewUrl: `/r/${meta.recordingId}`,
1118
+ };
1119
+ }
1011
1120
  } catch (err) {
1012
1121
  if (
1013
- await recoverReadyRecordingAfterFinalizeError({
1122
+ await recoverAcceptedRecordingAfterFinalizeError({
1014
1123
  serverUrl: meta.serverUrl,
1015
1124
  recordingId: meta.recordingId,
1016
1125
  authToken: input.authToken,
@@ -1143,6 +1252,7 @@ interface NativeFullscreenUploadResult {
1143
1252
  durationMs: number;
1144
1253
  width?: number;
1145
1254
  height?: number;
1255
+ verificationPending?: boolean;
1146
1256
  }
1147
1257
 
1148
1258
  interface NativeFullscreenSaveResult {
@@ -2490,7 +2600,7 @@ async function startNativeFullscreenRecording(
2490
2600
  uploadResult = await uploadPromise;
2491
2601
  } catch (err) {
2492
2602
  if (
2493
- await recoverReadyRecordingAfterFinalizeError({
2603
+ await recoverAcceptedRecordingAfterFinalizeError({
2494
2604
  serverUrl: params.serverUrl,
2495
2605
  recordingId: id,
2496
2606
  authToken: params.authToken,
@@ -2505,6 +2615,13 @@ async function startNativeFullscreenRecording(
2505
2615
  );
2506
2616
  throw err;
2507
2617
  }
2618
+ if (uploadResult.verificationPending) {
2619
+ scheduleNativeBackupCleanupAfterProcessing({
2620
+ serverUrl: params.serverUrl,
2621
+ recordingId: id,
2622
+ authToken: params.authToken,
2623
+ });
2624
+ }
2508
2625
  const transcriptSaved = await transcriptSavePromise;
2509
2626
  if (!transcriptSaved && capturedTranscript?.text.trim()) {
2510
2627
  // The first write runs in parallel with native upload. Retry once
@@ -3875,18 +3992,24 @@ async function startRecordingInner(
3875
3992
  `Finalize failed (${finalRes.status}): ${bodyText.slice(0, 200)}`,
3876
3993
  );
3877
3994
  }
3878
- const receipt = bodyText
3879
- ? (JSON.parse(bodyText) as FinalizeReceipt)
3880
- : null;
3881
- verifyFinalizeReceipt(receipt, {
3995
+ const receipt = parseFinalizeReceipt(bodyText);
3996
+ const receiptStatus = verifyFinalizeReceipt(receipt, {
3882
3997
  bytes: backupBytes,
3883
3998
  durationMs,
3884
3999
  });
4000
+ if (receiptStatus === "processing") {
4001
+ scheduleBrowserBackupCleanupAfterProcessing({
4002
+ serverUrl: params.serverUrl,
4003
+ recordingId: id,
4004
+ authToken: params.authToken,
4005
+ });
4006
+ return { recordingId: id, viewUrl };
4007
+ }
3885
4008
  } catch (err) {
3886
4009
  console.error("[clips-recorder] finalize fetch failed:", err);
3887
4010
  const error = err instanceof Error ? err : new Error(String(err));
3888
4011
  if (
3889
- await recoverReadyRecordingAfterFinalizeError({
4012
+ await recoverAcceptedRecordingAfterFinalizeError({
3890
4013
  serverUrl: params.serverUrl,
3891
4014
  recordingId: id,
3892
4015
  authToken: params.authToken,
@@ -2,6 +2,7 @@ export interface FinalizeReceipt {
2
2
  ok?: unknown;
3
3
  finalized?: unknown;
4
4
  status?: unknown;
5
+ verificationPending?: unknown;
5
6
  sourceSizeBytes?: unknown;
6
7
  durationMs?: unknown;
7
8
  }
@@ -11,16 +12,34 @@ export interface LocalRecordingProof {
11
12
  durationMs: number;
12
13
  }
13
14
 
15
+ export type FinalizeReceiptStatus = "processing" | "ready";
16
+
17
+ export function parseFinalizeReceipt(body: string): FinalizeReceipt | null {
18
+ if (!body) return null;
19
+ try {
20
+ const parsed: unknown = JSON.parse(body);
21
+ return parsed && typeof parsed === "object"
22
+ ? (parsed as FinalizeReceipt)
23
+ : null;
24
+ } catch {
25
+ throw new Error("Upload returned an invalid finalization response");
26
+ }
27
+ }
28
+
14
29
  export function verifyFinalizeReceipt(
15
30
  receipt: FinalizeReceipt | null,
16
31
  local: LocalRecordingProof,
17
- ): void {
18
- if (
19
- !receipt ||
20
- receipt.ok !== true ||
21
- receipt.finalized !== true ||
22
- receipt.status !== "ready"
23
- ) {
32
+ ): FinalizeReceiptStatus {
33
+ const ready =
34
+ receipt?.ok === true &&
35
+ receipt.finalized === true &&
36
+ receipt.status === "ready";
37
+ const processing =
38
+ receipt?.ok === true &&
39
+ receipt.finalized === false &&
40
+ receipt.status === "processing" &&
41
+ receipt.verificationPending === true;
42
+ if (!ready && !processing) {
24
43
  throw new Error(
25
44
  "Clip may be incomplete. Finalization was not confirmed; the local backup was kept.",
26
45
  );
@@ -48,4 +67,6 @@ export function verifyFinalizeReceipt(
48
67
  "Clip may be incomplete. The uploaded duration did not match the local recording; the local backup was kept.",
49
68
  );
50
69
  }
70
+
71
+ return ready ? "ready" : "processing";
51
72
  }
@@ -141,6 +141,7 @@ pub fn run() {
141
141
  native_screen::native_fullscreen_recording_rotate_segment,
142
142
  native_screen::native_fullscreen_pending_uploads,
143
143
  native_screen::native_fullscreen_recording_retry_upload,
144
+ native_screen::native_fullscreen_recording_mark_upload_error,
144
145
  native_screen::native_fullscreen_recording_dismiss_upload,
145
146
  native_screen::native_fullscreen_open_drafts_folder,
146
147
  // local-only always-on screen memory compatibility helpers
@@ -249,8 +250,7 @@ pub fn run() {
249
250
  use tauri_plugin_deep_link::DeepLinkExt;
250
251
  let dl_handle = app.handle().clone();
251
252
  app.deep_link().on_open_url(move |event| {
252
- let urls: Vec<String> =
253
- event.urls().iter().map(|u| u.to_string()).collect();
253
+ let urls: Vec<String> = event.urls().iter().map(|u| u.to_string()).collect();
254
254
  dlog!("[clips-tray] deep link opened: {:?}", urls);
255
255
  present_popover(&dl_handle);
256
256
  let _ = dl_handle.emit("clips:deep-link", urls);
@@ -263,8 +263,7 @@ pub fn run() {
263
263
  // arguments rather than an on_open_url event. Consume it here so
264
264
  // the popover appears and the browser does not hit its fallback.
265
265
  if let Ok(Some(urls)) = app.deep_link().get_current() {
266
- let url_strings: Vec<String> =
267
- urls.iter().map(|u| u.to_string()).collect();
266
+ let url_strings: Vec<String> = urls.iter().map(|u| u.to_string()).collect();
268
267
  dlog!("[clips-tray] deep link cold start: {:?}", url_strings);
269
268
  present_popover(app.handle());
270
269
  let _ = app.handle().emit("clips:deep-link", url_strings);
@@ -36,8 +36,12 @@ pub(super) struct LiveUploadCtrl {
36
36
 
37
37
  pub(super) struct LiveUpload {
38
38
  pub(super) ctrl: Arc<LiveUploadCtrl>,
39
- /// Resolves with the total bytes uploaded, or an error string.
40
- pub(super) result_rx: tokio::sync::oneshot::Receiver<Result<u64, String>>,
39
+ pub(super) result_rx: tokio::sync::oneshot::Receiver<Result<LiveUploadResult, String>>,
40
+ }
41
+
42
+ pub(super) struct LiveUploadResult {
43
+ pub(super) bytes: u64,
44
+ pub(super) verification_pending: bool,
41
45
  }
42
46
 
43
47
  struct LiveUploadParams {
@@ -163,7 +167,7 @@ async fn send_live_upload_post_with_retry(
163
167
  duration_ms: Option<u128>,
164
168
  expected_source_bytes: Option<u64>,
165
169
  bytes: &[u8],
166
- ) -> Result<(), String> {
170
+ ) -> Result<bool, String> {
167
171
  let rec = &params.recording_id;
168
172
  let mut attempt: u32 = 0;
169
173
  loop {
@@ -198,7 +202,7 @@ async fn send_live_upload_post_with_retry(
198
202
  )
199
203
  .await;
200
204
  let err = match result {
201
- Ok(()) => return Ok(()),
205
+ Ok(verification_pending) => return Ok(verification_pending),
202
206
  Err(err) => err,
203
207
  };
204
208
  if attempt >= LIVE_UPLOAD_CHUNK_ATTEMPTS {
@@ -219,7 +223,7 @@ async fn live_upload_loop(
219
223
  app: AppHandle,
220
224
  ctrl: Arc<LiveUploadCtrl>,
221
225
  params: LiveUploadParams,
222
- ) -> Result<u64, String> {
226
+ ) -> Result<LiveUploadResult, String> {
223
227
  let rec = params.recording_id.clone();
224
228
  eprintln!(
225
229
  "[live-upload] loop started for {rec}: file={} chunk={} bytes",
@@ -290,6 +294,7 @@ async fn live_upload_loop(
290
294
  // sentinel makes GCS silently commit only the aligned prefix, and
291
295
  // the session close then fails forever with 308 (incomplete).
292
296
  let mut final_sent = false;
297
+ let mut verification_pending = false;
293
298
  while final_len > offset {
294
299
  let take = chunk.min(final_len - offset);
295
300
  let is_last = offset + take >= final_len;
@@ -302,7 +307,7 @@ async fn live_upload_loop(
302
307
  eprintln!(
303
308
  "[live-upload] {rec}: sending tail chunk #{index} offset={offset} size={take} final={is_last}"
304
309
  );
305
- if let Err(e) = send_live_upload_post_with_retry(
310
+ let receipt = send_live_upload_post_with_retry(
306
311
  &client,
307
312
  &ctrl,
308
313
  &params,
@@ -313,10 +318,16 @@ async fn live_upload_loop(
313
318
  is_last.then_some(final_len),
314
319
  &bytes,
315
320
  )
316
- .await
317
- {
318
- eprintln!("[live-upload] {rec}: tail chunk #{index} failed: {e}");
319
- return Err(e);
321
+ .await;
322
+ let pending = match receipt {
323
+ Ok(pending) => pending,
324
+ Err(e) => {
325
+ eprintln!("[live-upload] {rec}: tail chunk #{index} failed: {e}");
326
+ return Err(e);
327
+ }
328
+ };
329
+ if is_last {
330
+ verification_pending = pending;
320
331
  }
321
332
  offset += take;
322
333
  index += 1;
@@ -331,7 +342,7 @@ async fn live_upload_loop(
331
342
  "[live-upload] {rec}: sending final post #{index} (total={}, duration_ms={duration_ms})",
332
343
  index + 1
333
344
  );
334
- if let Err(e) = send_live_upload_post_with_retry(
345
+ verification_pending = match send_live_upload_post_with_retry(
335
346
  &client,
336
347
  &ctrl,
337
348
  &params,
@@ -344,13 +355,19 @@ async fn live_upload_loop(
344
355
  )
345
356
  .await
346
357
  {
347
- eprintln!("[live-upload] {rec}: final post failed: {e}");
348
- return Err(e);
349
- }
358
+ Ok(pending) => pending,
359
+ Err(e) => {
360
+ eprintln!("[live-upload] {rec}: final post failed: {e}");
361
+ return Err(e);
362
+ }
363
+ };
350
364
  }
351
365
  emit_native_upload_progress(&app, "opening", "Uploading clip", None, Some(1.0));
352
366
  eprintln!("[live-upload] {rec}: done — {index} post(s), {final_len} bytes total");
353
- return Ok(final_len);
367
+ return Ok(LiveUploadResult {
368
+ bytes: final_len,
369
+ verification_pending,
370
+ });
354
371
  }
355
372
 
356
373
  if !wait_logged {
@@ -692,6 +692,7 @@ pub struct NativeFullscreenUploadResult {
692
692
  width: Option<u32>,
693
693
  height: Option<u32>,
694
694
  bytes: u64,
695
+ verification_pending: bool,
695
696
  }
696
697
 
697
698
  #[derive(Serialize, Clone)]
@@ -1388,20 +1389,23 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1388
1389
  eprintln!(
1389
1390
  "[live-upload] stop: finalize result for {recording_id}: {}",
1390
1391
  match &result {
1391
- Ok(bytes) => format!("ok ({bytes} bytes)"),
1392
+ Ok(upload) => format!("ok ({} bytes)", upload.bytes),
1392
1393
  Err(e) => format!("error: {e}"),
1393
1394
  }
1394
1395
  );
1395
1396
  return match result {
1396
- Ok(bytes) => {
1397
- clear_saved_recording_after_success(&app, &saved);
1397
+ Ok(upload) => {
1398
+ if !upload.verification_pending {
1399
+ clear_saved_recording_after_success(&app, &saved);
1400
+ }
1398
1401
  emit_native_upload_finished(&app, &server_url, &recording_id, true, None, None);
1399
1402
  Ok(NativeFullscreenUploadResult {
1400
1403
  recording_id,
1401
1404
  duration_ms: verified_duration_ms,
1402
1405
  width: session.width,
1403
1406
  height: session.height,
1404
- bytes,
1407
+ bytes: upload.bytes,
1408
+ verification_pending: upload.verification_pending,
1405
1409
  })
1406
1410
  }
1407
1411
  Err(err) => {
@@ -1481,7 +1485,9 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1481
1485
 
1482
1486
  match result {
1483
1487
  Ok(result) => {
1484
- clear_saved_recording_after_success(&app, &saved);
1488
+ if !result.verification_pending {
1489
+ clear_saved_recording_after_success(&app, &saved);
1490
+ }
1485
1491
  emit_native_upload_finished(&app, &server_url, &recording_id, true, None, None);
1486
1492
  Ok(result)
1487
1493
  }
@@ -2599,7 +2605,9 @@ pub async fn native_fullscreen_recording_retry_upload(
2599
2605
 
2600
2606
  match result {
2601
2607
  Ok(result) => {
2602
- clear_saved_recording_after_success(&app, &saved);
2608
+ if !result.verification_pending {
2609
+ clear_saved_recording_after_success(&app, &saved);
2610
+ }
2603
2611
  Ok(result)
2604
2612
  }
2605
2613
  Err(err) => {
@@ -2618,6 +2626,20 @@ pub async fn native_fullscreen_recording_retry_upload(
2618
2626
  }
2619
2627
  }
2620
2628
 
2629
+ #[tauri::command]
2630
+ pub async fn native_fullscreen_recording_mark_upload_error(
2631
+ app: AppHandle,
2632
+ recording_id: String,
2633
+ error: String,
2634
+ ) -> Result<(), String> {
2635
+ let mut saved = read_saved_recording_metadata(&app, &recording_id)?;
2636
+ saved.last_attempt_at = Some(now_iso());
2637
+ saved.last_error = Some(error);
2638
+ write_saved_recording_metadata(&app, &saved)?;
2639
+ let _ = app.emit("clips:pending-uploads-changed", ());
2640
+ Ok(())
2641
+ }
2642
+
2621
2643
  #[tauri::command]
2622
2644
  pub async fn native_fullscreen_recording_dismiss_upload(
2623
2645
  app: AppHandle,
@@ -4003,6 +4025,7 @@ async fn upload_prepared_recording_file(
4003
4025
  .map_err(|e| format!("upload client failed: {e}"))?;
4004
4026
  let mut file =
4005
4027
  File::open(&prepared.path).map_err(|e| format!("native recording open failed: {e}"))?;
4028
+ let verification_pending;
4006
4029
 
4007
4030
  if upload_mode == NativeUploadMode::Streaming {
4008
4031
  // Resumable providers require every non-final body to be aligned. The
@@ -4055,7 +4078,7 @@ async fn upload_prepared_recording_file(
4055
4078
  None,
4056
4079
  Some(streaming_full_chunks as f32 / total_posts as f32),
4057
4080
  );
4058
- send_upload_post(
4081
+ verification_pending = send_upload_post(
4059
4082
  &client,
4060
4083
  &server_url,
4061
4084
  &recording_id,
@@ -4123,7 +4146,7 @@ async fn upload_prepared_recording_file(
4123
4146
  None,
4124
4147
  Some(total_chunks as f32 / total_posts as f32),
4125
4148
  );
4126
- send_upload_post(
4149
+ verification_pending = send_upload_post(
4127
4150
  &client,
4128
4151
  &server_url,
4129
4152
  &recording_id,
@@ -4153,6 +4176,7 @@ async fn upload_prepared_recording_file(
4153
4176
  width,
4154
4177
  height,
4155
4178
  bytes: total_bytes,
4179
+ verification_pending,
4156
4180
  })
4157
4181
  }
4158
4182
 
@@ -4270,7 +4294,7 @@ async fn send_upload_post(
4270
4294
  locally_transcoded: bool,
4271
4295
  expected_source_bytes: Option<u64>,
4272
4296
  body: Vec<u8>,
4273
- ) -> Result<(), String> {
4297
+ ) -> Result<bool, String> {
4274
4298
  let body_len = body.len();
4275
4299
  let url = upload_url(
4276
4300
  server_url,
@@ -4319,7 +4343,7 @@ async fn send_upload_post(
4319
4343
  body.chars().take(400).collect::<String>()
4320
4344
  ));
4321
4345
  }
4322
- if is_final {
4346
+ let verification_pending = if is_final {
4323
4347
  verify_native_finalize_receipt(
4324
4348
  &body,
4325
4349
  expected_source_bytes.ok_or_else(|| {
@@ -4328,13 +4352,15 @@ async fn send_upload_post(
4328
4352
  duration_ms.ok_or_else(|| {
4329
4353
  "Clip may be incomplete: final upload had no local duration".to_string()
4330
4354
  })?,
4331
- )?;
4332
- }
4355
+ )?
4356
+ } else {
4357
+ false
4358
+ };
4333
4359
  eprintln!(
4334
4360
  "[clips-tray] native upload post ok recording={recording_id} mode={} index={index}/{total} final={is_final}",
4335
4361
  upload_mode.label()
4336
4362
  );
4337
- Ok(())
4363
+ Ok(verification_pending)
4338
4364
  }
4339
4365
 
4340
4366
  #[derive(Deserialize)]
@@ -4343,6 +4369,7 @@ struct NativeFinalizeReceipt {
4343
4369
  ok: bool,
4344
4370
  finalized: bool,
4345
4371
  status: Option<String>,
4372
+ verification_pending: Option<bool>,
4346
4373
  source_size_bytes: Option<u64>,
4347
4374
  duration_ms: Option<u128>,
4348
4375
  }
@@ -4351,12 +4378,17 @@ fn verify_native_finalize_receipt(
4351
4378
  body: &str,
4352
4379
  expected_source_bytes: u64,
4353
4380
  expected_duration_ms: u128,
4354
- ) -> Result<(), String> {
4381
+ ) -> Result<bool, String> {
4355
4382
  let receipt: NativeFinalizeReceipt = serde_json::from_str(body).map_err(|_| {
4356
4383
  "Clip may be incomplete. The final upload receipt was unreadable; the local backup was kept."
4357
4384
  .to_string()
4358
4385
  })?;
4359
- if !receipt.ok || !receipt.finalized || receipt.status.as_deref() != Some("ready") {
4386
+ let ready = receipt.ok && receipt.finalized && receipt.status.as_deref() == Some("ready");
4387
+ let processing = receipt.ok
4388
+ && !receipt.finalized
4389
+ && receipt.status.as_deref() == Some("processing")
4390
+ && receipt.verification_pending == Some(true);
4391
+ if !ready && !processing {
4360
4392
  return Err(
4361
4393
  "Clip may be incomplete. Finalization was not confirmed; the local backup was kept."
4362
4394
  .to_string(),
@@ -4374,7 +4406,7 @@ fn verify_native_finalize_receipt(
4374
4406
  .to_string(),
4375
4407
  );
4376
4408
  }
4377
- Ok(())
4409
+ Ok(processing)
4378
4410
  }
4379
4411
 
4380
4412
  fn media_durations_materially_match(expected_ms: u128, actual_ms: u128) -> bool {
@@ -4399,9 +4431,18 @@ mod native_finalize_receipt_tests {
4399
4431
  }
4400
4432
 
4401
4433
  #[test]
4402
- fn accepts_only_matching_ready_receipts() {
4434
+ fn accepts_matching_ready_and_pending_verification_receipts() {
4403
4435
  let ready = r#"{"ok":true,"finalized":true,"status":"ready","sourceSizeBytes":581614005,"durationMs":1593259}"#;
4404
- assert!(verify_native_finalize_receipt(ready, 581_614_005, 1_592_773).is_ok());
4436
+ assert_eq!(
4437
+ verify_native_finalize_receipt(ready, 581_614_005, 1_592_773),
4438
+ Ok(false)
4439
+ );
4440
+
4441
+ let processing = r#"{"ok":true,"finalized":false,"status":"processing","verificationPending":true,"sourceSizeBytes":581614005,"durationMs":1593259}"#;
4442
+ assert_eq!(
4443
+ verify_native_finalize_receipt(processing, 581_614_005, 1_592_773),
4444
+ Ok(true)
4445
+ );
4405
4446
 
4406
4447
  let short = r#"{"ok":true,"finalized":true,"status":"ready","sourceSizeBytes":581614005,"durationMs":483000}"#;
4407
4448
  assert!(
@@ -586,7 +586,15 @@ export const dictations = table("clips_dictations", {
586
586
  // are text-only since native recognition runs on-device.
587
587
  audioUrl: text("audio_url"),
588
588
  source: text("source", {
589
- enum: ["fn-hold", "cmd-shift-space", "manual", "other", "fn", "custom"],
589
+ enum: [
590
+ "fn-hold",
591
+ "cmd-shift-space",
592
+ "manual",
593
+ "mobile",
594
+ "other",
595
+ "fn",
596
+ "custom",
597
+ ],
590
598
  })
591
599
  .notNull()
592
600
  .default("fn-hold"),