@agent-native/core 0.134.1 → 0.135.1

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 (463) hide show
  1. package/corpus/README.md +3 -3
  2. package/corpus/core/CHANGELOG.md +113 -0
  3. package/corpus/core/package.json +3 -2
  4. package/corpus/core/src/automations/service.ts +41 -4
  5. package/corpus/core/src/cli/mcp-config-writers.ts +27 -21
  6. package/corpus/core/src/client/agent-page/AgentJobsTab.tsx +187 -85
  7. package/corpus/core/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
  8. package/corpus/core/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
  9. package/corpus/core/src/client/agent-page/TimezoneSelect.tsx +241 -0
  10. package/corpus/core/src/client/agent-page/use-jobs.ts +50 -10
  11. package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -0
  12. package/corpus/core/src/client/settings/AccountSettingsCard.tsx +35 -31
  13. package/corpus/core/src/client/settings/SchedulingTimezoneField.tsx +71 -0
  14. package/corpus/core/src/client/settings/SettingsRow.tsx +113 -0
  15. package/corpus/core/src/client/settings/agent-settings-search.ts +2 -1
  16. package/corpus/core/src/client/settings/index.ts +6 -0
  17. package/corpus/core/src/integrations/identity.ts +2 -12
  18. package/corpus/core/src/jobs/actions/list-automation-runs.ts +33 -0
  19. package/corpus/core/src/jobs/actions/list-recurring-jobs.ts +27 -6
  20. package/corpus/core/src/jobs/actions/manage-recurring-job.ts +41 -9
  21. package/corpus/core/src/jobs/background-automation-runner.ts +88 -0
  22. package/corpus/core/src/jobs/cron.ts +52 -6
  23. package/corpus/core/src/jobs/frontmatter.ts +20 -0
  24. package/corpus/core/src/jobs/run-history.ts +242 -0
  25. package/corpus/core/src/jobs/scheduler.ts +73 -18
  26. package/corpus/core/src/jobs/tools.ts +54 -6
  27. package/corpus/core/src/localization/actions/get-localization-preference.ts +2 -2
  28. package/corpus/core/src/localization/actions/set-localization-preference.ts +49 -11
  29. package/corpus/core/src/localization/default-messages.ts +27 -0
  30. package/corpus/core/src/localization/shared.ts +39 -6
  31. package/corpus/core/src/localization/user-timezone.ts +36 -0
  32. package/corpus/core/src/org/app-roles-handlers.ts +1 -9
  33. package/corpus/core/src/org/index.ts +2 -0
  34. package/corpus/core/src/org/membership.ts +18 -0
  35. package/corpus/core/src/review/actions/create-review-comment.ts +4 -1
  36. package/corpus/core/src/review/actions/reply-review-comment.ts +4 -1
  37. package/corpus/core/src/review/index.ts +5 -0
  38. package/corpus/core/src/review/notifications.ts +163 -0
  39. package/corpus/core/src/review/types.ts +7 -0
  40. package/corpus/core/src/server/action-discovery.ts +4 -0
  41. package/corpus/core/src/server/activity-notifications.ts +174 -0
  42. package/corpus/core/src/server/app-name.ts +31 -0
  43. package/corpus/core/src/server/better-auth-instance.ts +4 -4
  44. package/corpus/core/src/server/email-templates.ts +70 -14
  45. package/corpus/core/src/server/email.ts +66 -3
  46. package/corpus/core/src/server/index.ts +10 -0
  47. package/corpus/core/src/sharing/access.ts +44 -2
  48. package/corpus/core/src/sharing/index.ts +5 -0
  49. package/corpus/core/src/sharing/recipients.ts +67 -0
  50. package/corpus/core/src/triggers/actions/list-automations.ts +27 -8
  51. package/corpus/core/src/triggers/actions/manage-automation.ts +17 -6
  52. package/corpus/core/src/triggers/actions.ts +16 -4
  53. package/corpus/core/src/triggers/dispatcher.ts +44 -30
  54. package/corpus/core/src/triggers/routes.ts +19 -6
  55. package/corpus/core/src/vitest-config.ts +68 -0
  56. package/corpus/core/vitest.config.ts +5 -0
  57. package/corpus/templates/analytics/app/pages/Settings.tsx +37 -53
  58. package/corpus/templates/analytics/changelog/2026-08-01-new-error-alerts-now-reach-you-by-email-and-a-scheduled-dash.md +6 -0
  59. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +43 -0
  60. package/corpus/templates/analytics/server/lib/error-capture.ts +5 -1
  61. package/corpus/templates/analytics/vitest.config.ts +16 -12
  62. package/corpus/templates/assets/actions/refresh-generation-run.ts +3 -0
  63. package/corpus/templates/assets/app/components/ui/switch.tsx +1 -0
  64. package/corpus/templates/assets/app/hooks/use-assets-prefs.ts +55 -0
  65. package/corpus/templates/assets/app/i18n-data.ts +30 -0
  66. package/corpus/templates/assets/app/routes/settings.tsx +58 -55
  67. package/corpus/templates/assets/changelog/2026-08-01-get-an-email-when-a-generation-you-started-finishes-or-fails.md +6 -0
  68. package/corpus/templates/assets/server/lib/generation-run-notifications.ts +89 -0
  69. package/corpus/templates/assets/server/lib/video-runs.ts +8 -1
  70. package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.get.ts +17 -0
  71. package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.put.ts +36 -0
  72. package/corpus/templates/assets/shared/assets-user-prefs.ts +10 -0
  73. package/corpus/templates/assets/vitest.config.ts +16 -12
  74. package/corpus/templates/brain/app/routes/settings.tsx +15 -15
  75. package/corpus/templates/brain/vitest.config.ts +16 -12
  76. package/corpus/templates/calendar/app/pages/Settings.tsx +32 -40
  77. package/corpus/templates/calendar/changelog/2026-08-01-language-and-appearance-now-sit-together-in-one-preferences-.md +6 -0
  78. package/corpus/templates/calendar/vitest.config.ts +16 -12
  79. package/corpus/templates/chat/app/routes/settings.tsx +14 -22
  80. package/corpus/templates/chat/vitest.config.ts +9 -0
  81. package/corpus/templates/clips/.agents/skills/video-editing/SKILL.md +31 -0
  82. package/corpus/templates/clips/AGENTS.md +1 -0
  83. package/corpus/templates/clips/actions/add-comment.ts +12 -1
  84. package/corpus/templates/clips/actions/create-meeting.ts +2 -2
  85. package/corpus/templates/clips/actions/create-recording.ts +2 -2
  86. package/corpus/templates/clips/actions/finalize-recording.ts +42 -6
  87. package/corpus/templates/clips/actions/generate-filmstrip.ts +154 -0
  88. package/corpus/templates/clips/actions/get-recording-player-data.ts +6 -0
  89. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -2
  90. package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +258 -0
  91. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +11 -3
  92. package/corpus/templates/clips/actions/react-to-comment.ts +13 -0
  93. package/corpus/templates/clips/actions/react-to-recording.ts +10 -1
  94. package/corpus/templates/clips/actions/reply-to-comment.ts +12 -1
  95. package/corpus/templates/clips/actions/stitch-recordings.ts +2 -2
  96. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +152 -23
  97. package/corpus/templates/clips/app/components/editor/waveform.tsx +237 -75
  98. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +23 -1
  99. package/corpus/templates/clips/app/components/settings/ai-setup-section.tsx +441 -0
  100. package/corpus/templates/clips/app/components/settings/slack-section.tsx +329 -0
  101. package/corpus/templates/clips/app/components/settings/types.ts +14 -0
  102. package/corpus/templates/clips/app/components/settings/video-storage-section.tsx +443 -0
  103. package/corpus/templates/clips/app/hooks/use-secret-status.ts +69 -0
  104. package/corpus/templates/clips/app/i18n/en-US.ts +9 -1
  105. package/corpus/templates/clips/app/lib/video-filmstrip.ts +262 -0
  106. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +165 -1351
  107. package/corpus/templates/clips/changelog/2026-07-27-saved-clips-now-resume-from-the-last-uploaded-byte-after-an-.md +6 -0
  108. package/corpus/templates/clips/changelog/2026-07-31-editor-timeline-now-shows-video-frames-behind-the-waveform-g.md +6 -0
  109. package/corpus/templates/clips/changelog/2026-07-31-email-notifications-now-go-out-when-someone-comments-on-or-r.md +6 -0
  110. package/corpus/templates/clips/changelog/2026-07-31-you-can-now-set-a-default-visibility-for-new-recordings-in-s.md +6 -0
  111. package/corpus/templates/clips/changelog/2026-08-01-email-notifications-for-comments-and-reactions-now-actually-.md +6 -0
  112. package/corpus/templates/clips/changelog/2026-08-03-the-desktop-app-now-opens-directly-beneath-its-tray-icon-on-.md +6 -0
  113. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  114. package/corpus/templates/clips/chrome-extension/vitest.config.ts +9 -0
  115. package/corpus/templates/clips/desktop/package.json +1 -0
  116. package/corpus/templates/clips/desktop/src/app.tsx +17 -1
  117. package/corpus/templates/clips/desktop/src/lib/recorder.ts +331 -36
  118. package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +129 -0
  119. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +815 -15
  120. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +61 -11
  121. package/corpus/templates/clips/desktop/vitest.config.ts +9 -0
  122. package/corpus/templates/clips/server/db/schema.ts +17 -0
  123. package/corpus/templates/clips/server/jobs/media-verification.ts +4 -0
  124. package/corpus/templates/clips/server/lib/activity-notifications.ts +185 -0
  125. package/corpus/templates/clips/server/lib/recording-media-cleanup.ts +2 -0
  126. package/corpus/templates/clips/server/lib/recording-upload-state.ts +46 -9
  127. package/corpus/templates/clips/server/lib/recordings.ts +24 -0
  128. package/corpus/templates/clips/server/lib/resumable-session.ts +10 -4
  129. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +109 -0
  130. package/corpus/templates/clips/server/lib/upload-interruption.ts +8 -0
  131. package/corpus/templates/clips/server/lib/upload-lease.ts +50 -4
  132. package/corpus/templates/clips/server/lib/video-filmstrip-sprite.ts +207 -0
  133. package/corpus/templates/clips/server/lib/video-remux.ts +13 -4
  134. package/corpus/templates/clips/server/plugins/db.ts +16 -0
  135. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +4 -0
  136. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +123 -36
  137. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +159 -29
  138. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +197 -0
  139. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +193 -39
  140. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +191 -12
  141. package/corpus/templates/clips/shared/clips-ai-prefs.ts +6 -0
  142. package/corpus/templates/clips/shared/feature-flags.ts +12 -0
  143. package/corpus/templates/clips/shared/recording-core.ts +4 -0
  144. package/corpus/templates/clips/vitest.config.ts +23 -19
  145. package/corpus/templates/content/actions/_database-utils.ts +113 -4
  146. package/corpus/templates/content/actions/add-comment.ts +15 -1
  147. package/corpus/templates/content/actions/get-document.ts +84 -3
  148. package/corpus/templates/content/actions/list-documents.ts +1 -0
  149. package/corpus/templates/content/app/components/editor/BuilderBodySyncingNotice.tsx +9 -2
  150. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +34 -15
  151. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +55 -13
  152. package/corpus/templates/content/app/components/editor/body-hydration.ts +12 -6
  153. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2 -3
  154. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -12
  155. package/corpus/templates/content/app/components/ui/switch.tsx +1 -0
  156. package/corpus/templates/content/app/hooks/use-content-database.ts +16 -27
  157. package/corpus/templates/content/app/hooks/use-content-prefs.ts +55 -0
  158. package/corpus/templates/content/app/hooks/use-create-page.ts +3 -6
  159. package/corpus/templates/content/app/hooks/use-document-properties.ts +9 -16
  160. package/corpus/templates/content/app/hooks/use-document-versions.ts +3 -3
  161. package/corpus/templates/content/app/hooks/use-documents.ts +85 -29
  162. package/corpus/templates/content/app/hooks/use-notion.ts +15 -13
  163. package/corpus/templates/content/app/i18n-data.ts +72 -1
  164. package/corpus/templates/content/app/lib/document-query.ts +36 -0
  165. package/corpus/templates/content/app/routes/_app.settings.tsx +47 -18
  166. package/corpus/templates/content/changelog/2026-07-30-breadcrumb-menus-now-hide-internal-system-pages.md +6 -0
  167. package/corpus/templates/content/changelog/2026-08-01-get-an-email-when-someone-comments-on-replies-in-or-mentions.md +6 -0
  168. package/corpus/templates/content/changelog/2026-08-02-pages-opened-from-a-database-now-keep-that-database-s-fields.md +6 -0
  169. package/corpus/templates/content/server/lib/comment-notifications.ts +146 -0
  170. package/corpus/templates/content/server/lib/document-context.ts +11 -6
  171. package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.get.ts +17 -0
  172. package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.put.ts +36 -0
  173. package/corpus/templates/content/shared/api.ts +8 -0
  174. package/corpus/templates/content/shared/content-user-prefs.ts +11 -0
  175. package/corpus/templates/content/vitest.config.ts +20 -16
  176. package/corpus/templates/crm/app/routes/settings.tsx +14 -22
  177. package/corpus/templates/crm/vitest.config.ts +8 -4
  178. package/corpus/templates/design/app/routes/settings.tsx +14 -22
  179. package/corpus/templates/design/changelog/2026-08-01-review-comments-replies-and-mentions-now-send-an-email-to-th.md +6 -0
  180. package/corpus/templates/design/vitest.config.ts +18 -12
  181. package/corpus/templates/dispatch/app/routes/settings.tsx +26 -40
  182. package/corpus/templates/dispatch/vitest.config.ts +11 -7
  183. package/corpus/templates/factory/app/routes/settings.tsx +14 -22
  184. package/corpus/templates/factory/vitest.config.ts +9 -0
  185. package/corpus/templates/forms/app/routes/_app.settings.tsx +14 -22
  186. package/corpus/templates/forms/vitest.config.ts +27 -23
  187. package/corpus/templates/macros/app/routes/settings.tsx +14 -22
  188. package/corpus/templates/macros/vitest.config.ts +16 -12
  189. package/corpus/templates/mail/app/pages/SettingsPage.tsx +14 -16
  190. package/corpus/templates/mail/vitest.config.ts +16 -12
  191. package/corpus/templates/plan/app/routes/settings.tsx +30 -44
  192. package/corpus/templates/plan/vitest.config.ts +24 -20
  193. package/corpus/templates/slides/actions/add-slide-comment.ts +12 -1
  194. package/corpus/templates/slides/app/components/ui/switch.tsx +1 -0
  195. package/corpus/templates/slides/app/hooks/use-slides-prefs.ts +55 -0
  196. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  197. package/corpus/templates/slides/app/routes/settings.tsx +45 -22
  198. package/corpus/templates/slides/changelog/2026-08-01-get-an-email-when-someone-comments-on-or-replies-in-your-dec.md +6 -0
  199. package/corpus/templates/slides/server/lib/comment-notifications.ts +182 -0
  200. package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.get.ts +16 -0
  201. package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.put.ts +36 -0
  202. package/corpus/templates/slides/shared/slides-user-prefs.ts +11 -0
  203. package/corpus/templates/slides/vitest.config.ts +17 -13
  204. package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +2 -0
  205. package/corpus/templates/tasks/app/routes/settings.tsx +14 -22
  206. package/corpus/templates/tasks/vitest.config.ts +17 -13
  207. package/corpus/toolkit/CHANGELOG.md +9 -0
  208. package/corpus/toolkit/package.json +1 -1
  209. package/corpus/toolkit/vitest.config.ts +3 -0
  210. package/dist/automations/service.d.ts +2 -0
  211. package/dist/automations/service.d.ts.map +1 -1
  212. package/dist/automations/service.js +29 -4
  213. package/dist/automations/service.js.map +1 -1
  214. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  215. package/dist/cli/mcp-config-writers.js +24 -23
  216. package/dist/cli/mcp-config-writers.js.map +1 -1
  217. package/dist/client/agent-page/AgentJobsTab.d.ts.map +1 -1
  218. package/dist/client/agent-page/AgentJobsTab.js +86 -29
  219. package/dist/client/agent-page/AgentJobsTab.js.map +1 -1
  220. package/dist/client/agent-page/AutomationDetailsDialog.d.ts +21 -0
  221. package/dist/client/agent-page/AutomationDetailsDialog.d.ts.map +1 -0
  222. package/dist/client/agent-page/AutomationDetailsDialog.js +33 -0
  223. package/dist/client/agent-page/AutomationDetailsDialog.js.map +1 -0
  224. package/dist/client/agent-page/AutomationScheduleDialog.d.ts +15 -0
  225. package/dist/client/agent-page/AutomationScheduleDialog.d.ts.map +1 -0
  226. package/dist/client/agent-page/AutomationScheduleDialog.js +46 -0
  227. package/dist/client/agent-page/AutomationScheduleDialog.js.map +1 -0
  228. package/dist/client/agent-page/TimezoneSelect.d.ts +17 -0
  229. package/dist/client/agent-page/TimezoneSelect.d.ts.map +1 -0
  230. package/dist/client/agent-page/TimezoneSelect.js +107 -0
  231. package/dist/client/agent-page/TimezoneSelect.js.map +1 -0
  232. package/dist/client/agent-page/use-jobs.d.ts +20 -7
  233. package/dist/client/agent-page/use-jobs.d.ts.map +1 -1
  234. package/dist/client/agent-page/use-jobs.js +24 -4
  235. package/dist/client/agent-page/use-jobs.js.map +1 -1
  236. package/dist/client/org/OrgSwitcher.d.ts +8 -1
  237. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  238. package/dist/client/org/OrgSwitcher.js +8 -2
  239. package/dist/client/org/OrgSwitcher.js.map +1 -1
  240. package/dist/client/settings/AccountSettingsCard.d.ts.map +1 -1
  241. package/dist/client/settings/AccountSettingsCard.js +10 -9
  242. package/dist/client/settings/AccountSettingsCard.js.map +1 -1
  243. package/dist/client/settings/SchedulingTimezoneField.d.ts +10 -0
  244. package/dist/client/settings/SchedulingTimezoneField.d.ts.map +1 -0
  245. package/dist/client/settings/SchedulingTimezoneField.js +30 -0
  246. package/dist/client/settings/SchedulingTimezoneField.js.map +1 -0
  247. package/dist/client/settings/SettingsRow.d.ts +36 -0
  248. package/dist/client/settings/SettingsRow.d.ts.map +1 -0
  249. package/dist/client/settings/SettingsRow.js +15 -0
  250. package/dist/client/settings/SettingsRow.js.map +1 -0
  251. package/dist/client/settings/agent-settings-search.d.ts.map +1 -1
  252. package/dist/client/settings/agent-settings-search.js +1 -1
  253. package/dist/client/settings/agent-settings-search.js.map +1 -1
  254. package/dist/client/settings/index.d.ts +1 -0
  255. package/dist/client/settings/index.d.ts.map +1 -1
  256. package/dist/client/settings/index.js +1 -0
  257. package/dist/client/settings/index.js.map +1 -1
  258. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  259. package/dist/integrations/identity.d.ts.map +1 -1
  260. package/dist/integrations/identity.js +2 -11
  261. package/dist/integrations/identity.js.map +1 -1
  262. package/dist/jobs/actions/list-automation-runs.d.ts +8 -0
  263. package/dist/jobs/actions/list-automation-runs.d.ts.map +1 -0
  264. package/dist/jobs/actions/list-automation-runs.js +29 -0
  265. package/dist/jobs/actions/list-automation-runs.js.map +1 -0
  266. package/dist/jobs/actions/list-recurring-jobs.d.ts +2 -0
  267. package/dist/jobs/actions/list-recurring-jobs.d.ts.map +1 -1
  268. package/dist/jobs/actions/list-recurring-jobs.js +20 -7
  269. package/dist/jobs/actions/list-recurring-jobs.js.map +1 -1
  270. package/dist/jobs/actions/manage-recurring-job.d.ts +2 -0
  271. package/dist/jobs/actions/manage-recurring-job.js +30 -9
  272. package/dist/jobs/actions/manage-recurring-job.js.map +1 -1
  273. package/dist/jobs/background-automation-runner.d.ts.map +1 -1
  274. package/dist/jobs/background-automation-runner.js +60 -0
  275. package/dist/jobs/background-automation-runner.js.map +1 -1
  276. package/dist/jobs/cron.d.ts +16 -2
  277. package/dist/jobs/cron.d.ts.map +1 -1
  278. package/dist/jobs/cron.js +43 -6
  279. package/dist/jobs/cron.js.map +1 -1
  280. package/dist/jobs/frontmatter.d.ts +12 -0
  281. package/dist/jobs/frontmatter.d.ts.map +1 -1
  282. package/dist/jobs/frontmatter.js +8 -0
  283. package/dist/jobs/frontmatter.js.map +1 -1
  284. package/dist/jobs/run-history.d.ts +56 -0
  285. package/dist/jobs/run-history.d.ts.map +1 -0
  286. package/dist/jobs/run-history.js +169 -0
  287. package/dist/jobs/run-history.js.map +1 -0
  288. package/dist/jobs/scheduler.d.ts.map +1 -1
  289. package/dist/jobs/scheduler.js +51 -18
  290. package/dist/jobs/scheduler.js.map +1 -1
  291. package/dist/jobs/tools.d.ts.map +1 -1
  292. package/dist/jobs/tools.js +37 -6
  293. package/dist/jobs/tools.js.map +1 -1
  294. package/dist/localization/actions/get-localization-preference.d.ts +1 -2
  295. package/dist/localization/actions/get-localization-preference.d.ts.map +1 -1
  296. package/dist/localization/actions/get-localization-preference.js.map +1 -1
  297. package/dist/localization/actions/set-localization-preference.d.ts +3 -3
  298. package/dist/localization/actions/set-localization-preference.d.ts.map +1 -1
  299. package/dist/localization/actions/set-localization-preference.js +33 -8
  300. package/dist/localization/actions/set-localization-preference.js.map +1 -1
  301. package/dist/localization/default-messages.d.ts +25 -0
  302. package/dist/localization/default-messages.d.ts.map +1 -1
  303. package/dist/localization/default-messages.js +25 -0
  304. package/dist/localization/default-messages.js.map +1 -1
  305. package/dist/localization/shared.d.ts +16 -1
  306. package/dist/localization/shared.d.ts.map +1 -1
  307. package/dist/localization/shared.js +22 -4
  308. package/dist/localization/shared.js.map +1 -1
  309. package/dist/localization/user-timezone.d.ts +11 -0
  310. package/dist/localization/user-timezone.d.ts.map +1 -0
  311. package/dist/localization/user-timezone.js +31 -0
  312. package/dist/localization/user-timezone.js.map +1 -0
  313. package/dist/notifications/routes.d.ts +4 -4
  314. package/dist/observability/routes.d.ts +3 -3
  315. package/dist/org/app-roles-handlers.d.ts.map +1 -1
  316. package/dist/org/app-roles-handlers.js +1 -8
  317. package/dist/org/app-roles-handlers.js.map +1 -1
  318. package/dist/org/index.d.ts +1 -0
  319. package/dist/org/index.d.ts.map +1 -1
  320. package/dist/org/index.js +1 -0
  321. package/dist/org/index.js.map +1 -1
  322. package/dist/org/membership.d.ts +6 -0
  323. package/dist/org/membership.d.ts.map +1 -0
  324. package/dist/org/membership.js +16 -0
  325. package/dist/org/membership.js.map +1 -0
  326. package/dist/progress/routes.d.ts +1 -1
  327. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  328. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  329. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  330. package/dist/review/actions/create-review-comment.d.ts +32 -1
  331. package/dist/review/actions/create-review-comment.d.ts.map +1 -1
  332. package/dist/review/actions/create-review-comment.js +3 -1
  333. package/dist/review/actions/create-review-comment.js.map +1 -1
  334. package/dist/review/actions/reply-review-comment.d.ts +32 -1
  335. package/dist/review/actions/reply-review-comment.d.ts.map +1 -1
  336. package/dist/review/actions/reply-review-comment.js +3 -1
  337. package/dist/review/actions/reply-review-comment.js.map +1 -1
  338. package/dist/review/index.d.ts +1 -0
  339. package/dist/review/index.d.ts.map +1 -1
  340. package/dist/review/index.js +1 -0
  341. package/dist/review/index.js.map +1 -1
  342. package/dist/review/notifications.d.ts +24 -0
  343. package/dist/review/notifications.d.ts.map +1 -0
  344. package/dist/review/notifications.js +123 -0
  345. package/dist/review/notifications.js.map +1 -0
  346. package/dist/review/types.d.ts +5 -0
  347. package/dist/review/types.d.ts.map +1 -1
  348. package/dist/review/types.js.map +1 -1
  349. package/dist/server/action-discovery.d.ts.map +1 -1
  350. package/dist/server/action-discovery.js +4 -0
  351. package/dist/server/action-discovery.js.map +1 -1
  352. package/dist/server/activity-notifications.d.ts +61 -0
  353. package/dist/server/activity-notifications.d.ts.map +1 -0
  354. package/dist/server/activity-notifications.js +103 -0
  355. package/dist/server/activity-notifications.js.map +1 -0
  356. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  357. package/dist/server/app-name.d.ts +18 -0
  358. package/dist/server/app-name.d.ts.map +1 -1
  359. package/dist/server/app-name.js +31 -0
  360. package/dist/server/app-name.js.map +1 -1
  361. package/dist/server/better-auth-instance.js +4 -4
  362. package/dist/server/better-auth-instance.js.map +1 -1
  363. package/dist/server/email-templates.d.ts +12 -0
  364. package/dist/server/email-templates.d.ts.map +1 -1
  365. package/dist/server/email-templates.js +57 -14
  366. package/dist/server/email-templates.js.map +1 -1
  367. package/dist/server/email.d.ts +12 -0
  368. package/dist/server/email.d.ts.map +1 -1
  369. package/dist/server/email.js +52 -5
  370. package/dist/server/email.js.map +1 -1
  371. package/dist/server/index.d.ts +1 -0
  372. package/dist/server/index.d.ts.map +1 -1
  373. package/dist/server/index.js +1 -0
  374. package/dist/server/index.js.map +1 -1
  375. package/dist/server/transcribe-voice.d.ts +1 -1
  376. package/dist/sharing/access.d.ts.map +1 -1
  377. package/dist/sharing/access.js +32 -2
  378. package/dist/sharing/access.js.map +1 -1
  379. package/dist/sharing/index.d.ts +1 -0
  380. package/dist/sharing/index.d.ts.map +1 -1
  381. package/dist/sharing/index.js +1 -0
  382. package/dist/sharing/index.js.map +1 -1
  383. package/dist/sharing/recipients.d.ts +31 -0
  384. package/dist/sharing/recipients.d.ts.map +1 -0
  385. package/dist/sharing/recipients.js +40 -0
  386. package/dist/sharing/recipients.js.map +1 -0
  387. package/dist/templates/chat/app/routes/settings.tsx +14 -22
  388. package/dist/templates/chat/vitest.config.ts +9 -0
  389. package/dist/triggers/actions/list-automations.d.ts +2 -0
  390. package/dist/triggers/actions/list-automations.d.ts.map +1 -1
  391. package/dist/triggers/actions/list-automations.js +19 -8
  392. package/dist/triggers/actions/list-automations.js.map +1 -1
  393. package/dist/triggers/actions/manage-automation.d.ts +6 -0
  394. package/dist/triggers/actions/manage-automation.js +12 -6
  395. package/dist/triggers/actions/manage-automation.js.map +1 -1
  396. package/dist/triggers/actions.d.ts.map +1 -1
  397. package/dist/triggers/actions.js +15 -4
  398. package/dist/triggers/actions.js.map +1 -1
  399. package/dist/triggers/dispatcher.d.ts.map +1 -1
  400. package/dist/triggers/dispatcher.js +19 -17
  401. package/dist/triggers/dispatcher.js.map +1 -1
  402. package/dist/triggers/routes.d.ts.map +1 -1
  403. package/dist/triggers/routes.js +6 -6
  404. package/dist/triggers/routes.js.map +1 -1
  405. package/dist/vitest-config.d.ts +10 -0
  406. package/dist/vitest-config.d.ts.map +1 -0
  407. package/dist/vitest-config.js +55 -0
  408. package/dist/vitest-config.js.map +1 -0
  409. package/package.json +5 -4
  410. package/src/automations/service.ts +41 -4
  411. package/src/cli/mcp-config-writers.ts +27 -21
  412. package/src/client/agent-page/AgentJobsTab.tsx +187 -85
  413. package/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
  414. package/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
  415. package/src/client/agent-page/TimezoneSelect.tsx +241 -0
  416. package/src/client/agent-page/use-jobs.ts +50 -10
  417. package/src/client/org/OrgSwitcher.tsx +26 -0
  418. package/src/client/settings/AccountSettingsCard.tsx +35 -31
  419. package/src/client/settings/SchedulingTimezoneField.tsx +71 -0
  420. package/src/client/settings/SettingsRow.tsx +113 -0
  421. package/src/client/settings/agent-settings-search.ts +2 -1
  422. package/src/client/settings/index.ts +6 -0
  423. package/src/integrations/identity.ts +2 -12
  424. package/src/jobs/actions/list-automation-runs.ts +33 -0
  425. package/src/jobs/actions/list-recurring-jobs.ts +27 -6
  426. package/src/jobs/actions/manage-recurring-job.ts +41 -9
  427. package/src/jobs/background-automation-runner.ts +88 -0
  428. package/src/jobs/cron.ts +52 -6
  429. package/src/jobs/frontmatter.ts +20 -0
  430. package/src/jobs/run-history.ts +242 -0
  431. package/src/jobs/scheduler.ts +73 -18
  432. package/src/jobs/tools.ts +54 -6
  433. package/src/localization/actions/get-localization-preference.ts +2 -2
  434. package/src/localization/actions/set-localization-preference.ts +49 -11
  435. package/src/localization/default-messages.ts +27 -0
  436. package/src/localization/shared.ts +39 -6
  437. package/src/localization/user-timezone.ts +36 -0
  438. package/src/org/app-roles-handlers.ts +1 -9
  439. package/src/org/index.ts +2 -0
  440. package/src/org/membership.ts +18 -0
  441. package/src/review/actions/create-review-comment.ts +4 -1
  442. package/src/review/actions/reply-review-comment.ts +4 -1
  443. package/src/review/index.ts +5 -0
  444. package/src/review/notifications.ts +163 -0
  445. package/src/review/types.ts +7 -0
  446. package/src/server/action-discovery.ts +4 -0
  447. package/src/server/activity-notifications.ts +174 -0
  448. package/src/server/app-name.ts +31 -0
  449. package/src/server/better-auth-instance.ts +4 -4
  450. package/src/server/email-templates.ts +70 -14
  451. package/src/server/email.ts +66 -3
  452. package/src/server/index.ts +10 -0
  453. package/src/sharing/access.ts +44 -2
  454. package/src/sharing/index.ts +5 -0
  455. package/src/sharing/recipients.ts +67 -0
  456. package/src/templates/chat/app/routes/settings.tsx +14 -22
  457. package/src/templates/chat/vitest.config.ts +9 -0
  458. package/src/triggers/actions/list-automations.ts +27 -8
  459. package/src/triggers/actions/manage-automation.ts +17 -6
  460. package/src/triggers/actions.ts +16 -4
  461. package/src/triggers/dispatcher.ts +44 -30
  462. package/src/triggers/routes.ts +19 -6
  463. package/src/vitest-config.ts +68 -0
@@ -157,6 +157,10 @@ const FFMPEG_CANDIDATE_PATHS: &[&str] = &[
157
157
  ];
158
158
  const PENDING_UPLOADS_DIR: &str = "pending-recording-uploads";
159
159
  const CLIP_DRAFTS_DIR: &str = "Drafts";
160
+ const NATIVE_UPLOAD_RESTART_REQUIRED: &str = "native upload requires a one-time restart";
161
+ const NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED: &str =
162
+ "native upload requires an unfenced one-time restart";
163
+ static NATIVE_RETRY_CLAIM_COUNTER: AtomicU64 = AtomicU64::new(1);
160
164
  // Minimum free space required to start recording; below this we hard-block.
161
165
  pub(crate) const DISK_SPACE_BLOCK_BYTES: u64 = 500 * 1024 * 1024;
162
166
  // Free space below this at start time is logged as a warning but not blocked.
@@ -174,6 +178,51 @@ pub(crate) enum NativeUploadMode {
174
178
  Streaming,
175
179
  }
176
180
 
181
+ #[derive(Debug, Clone)]
182
+ struct NativeStreamingResume {
183
+ bytes_received: u64,
184
+ next_chunk_index: usize,
185
+ attempt_id: Option<String>,
186
+ upload_generation_id: Option<String>,
187
+ }
188
+
189
+ #[derive(Debug, Clone)]
190
+ enum NativeRetryUploadPlan {
191
+ Resume(NativeStreamingResume),
192
+ Restart {
193
+ attempt_id: Option<String>,
194
+ upload_generation_id: Option<String>,
195
+ },
196
+ Reconcile,
197
+ }
198
+
199
+ #[derive(Debug, Deserialize)]
200
+ #[serde(rename_all = "camelCase")]
201
+ struct NativeUploadResumeResponse {
202
+ resumable: bool,
203
+ #[serde(default)]
204
+ recovery_enabled: bool,
205
+ status: Option<String>,
206
+ upload_mode: Option<String>,
207
+ bytes_received: Option<u64>,
208
+ next_chunk_index: Option<u64>,
209
+ attempt_id: Option<String>,
210
+ upload_generation_id: Option<String>,
211
+ }
212
+
213
+ #[derive(Debug, Deserialize)]
214
+ #[serde(rename_all = "camelCase")]
215
+ struct NativeUploadResetResponse {
216
+ upload_mode: Option<String>,
217
+ upload_generation_id: Option<String>,
218
+ }
219
+
220
+ impl NativeUploadResetResponse {
221
+ fn mode(&self) -> NativeUploadMode {
222
+ NativeUploadMode::from_option(self.upload_mode.clone())
223
+ }
224
+ }
225
+
177
226
  impl NativeUploadMode {
178
227
  pub(crate) fn from_option(value: Option<String>) -> Self {
179
228
  match value.as_deref() {
@@ -189,6 +238,7 @@ impl NativeUploadMode {
189
238
  }
190
239
  }
191
240
 
241
+ #[cfg(test)]
192
242
  fn from_reset_response(body: &str) -> Self {
193
243
  let value = serde_json::from_str::<serde_json::Value>(body).ok();
194
244
  let upload_mode = value
@@ -528,6 +578,8 @@ impl SharedClipSink {
528
578
  &server_url,
529
579
  &recording_id,
530
580
  MP4_RECORDING_MIME_TYPE,
581
+ None,
582
+ None,
531
583
  &auth_token,
532
584
  &cookie,
533
585
  )
@@ -966,6 +1018,8 @@ struct SavedNativeRecording {
966
1018
  last_error: Option<String>,
967
1019
  retry_count: u32,
968
1020
  #[serde(default)]
1021
+ retry_attempt_id: Option<String>,
1022
+ #[serde(default)]
969
1023
  custom_pipeline: bool,
970
1024
  /// True when the SCK finalization callback reported an error, meaning the
971
1025
  /// MP4 is missing its moov atom and cannot be recovered by retrying.
@@ -1873,6 +1927,8 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1873
1927
  &server_url,
1874
1928
  &recording_id,
1875
1929
  session.mime_type,
1930
+ None,
1931
+ None,
1876
1932
  &auth_token,
1877
1933
  &cookie,
1878
1934
  )
@@ -2967,6 +3023,7 @@ pub async fn native_fullscreen_recording_retry_upload(
2967
3023
  saved.server_url = server_url.trim_end_matches('/').to_string();
2968
3024
  saved.last_attempt_at = Some(now_iso());
2969
3025
  saved.last_error = None;
3026
+ let claimed_attempt_id = saved_native_retry_attempt_id(&mut saved.retry_attempt_id);
2970
3027
  write_saved_recording_metadata(&app, &saved)?;
2971
3028
 
2972
3029
  // Preparation can normalize or transcode the saved recording. The
@@ -2974,37 +3031,208 @@ pub async fn native_fullscreen_recording_retry_upload(
2974
3031
  // upload, not the source file's potentially stale MIME type.
2975
3032
  let result = async {
2976
3033
  let (prepared, retry_combined_path) = prepare_saved_recording_file(&app, &saved)?;
2977
- let upload_mode = match reset_upload_chunks(
3034
+ let auth_token = auth_token.unwrap_or_default();
3035
+ let cookie = cookie.unwrap_or_default();
3036
+ // A resume offset only applies to the exact byte stream previously
3037
+ // uploaded. Any retry-time concat/transcode creates different bytes,
3038
+ // so take the explicit reset path instead of skipping an unsafe prefix.
3039
+ let can_resume_exact_stream = !prepared.temporary && retry_combined_path.is_none();
3040
+ // This saved claim survives response loss and later user retries, so a
3041
+ // second click cannot steal an upload session already owned by this
3042
+ // local recording.
3043
+ let retry_plan = match get_native_retry_upload_plan(
2978
3044
  &saved.server_url,
2979
3045
  &saved.recording_id,
2980
- &prepared.mime_type,
2981
- auth_token.as_deref().unwrap_or(""),
2982
- cookie.as_deref().unwrap_or(""),
3046
+ prepared.bytes,
3047
+ can_resume_exact_stream,
3048
+ &claimed_attempt_id,
3049
+ &auth_token,
3050
+ &cookie,
2983
3051
  )
2984
3052
  .await
2985
3053
  {
2986
- Ok(upload_mode) => upload_mode,
3054
+ Ok(plan) => plan,
2987
3055
  Err(err) => {
3056
+ interrupt_native_retry_upload(
3057
+ &saved.server_url,
3058
+ &saved.recording_id,
3059
+ &err,
3060
+ Some(&claimed_attempt_id),
3061
+ None,
3062
+ &auth_token,
3063
+ &cookie,
3064
+ )
3065
+ .await;
2988
3066
  cleanup_prepared_saved_recording_files(&prepared, retry_combined_path);
2989
3067
  return Err(err);
2990
3068
  }
2991
3069
  };
2992
3070
 
3071
+ let active_attempt_id = match &retry_plan {
3072
+ NativeRetryUploadPlan::Resume(resume) => resume.attempt_id.clone(),
3073
+ NativeRetryUploadPlan::Restart { attempt_id, .. } => attempt_id.clone(),
3074
+ NativeRetryUploadPlan::Reconcile => None,
3075
+ };
3076
+ let active_upload_generation_id = match &retry_plan {
3077
+ NativeRetryUploadPlan::Resume(resume) => resume.upload_generation_id.clone(),
3078
+ NativeRetryUploadPlan::Restart {
3079
+ upload_generation_id,
3080
+ ..
3081
+ } => upload_generation_id.clone(),
3082
+ NativeRetryUploadPlan::Reconcile => None,
3083
+ };
3084
+
3085
+ if let NativeRetryUploadPlan::Reconcile = retry_plan {
3086
+ cleanup_prepared_saved_recording_files(&prepared, retry_combined_path);
3087
+ return Ok(NativeFullscreenUploadResult {
3088
+ recording_id: saved.recording_id.clone(),
3089
+ duration_ms: saved.duration_ms,
3090
+ width: saved.width,
3091
+ height: saved.height,
3092
+ bytes: prepared.bytes,
3093
+ // Keep the backup until the ordinary owner-status reconciliation
3094
+ // proves the accepted media is ready and complete.
3095
+ verification_pending: true,
3096
+ });
3097
+ }
3098
+
3099
+ let (upload_mode, streaming_resume, upload_generation_id) = match retry_plan {
3100
+ NativeRetryUploadPlan::Resume(resume) => {
3101
+ emit_native_upload_progress(
3102
+ &app,
3103
+ "uploading",
3104
+ "Resuming upload",
3105
+ None,
3106
+ Some(resume.bytes_received as f32 / prepared.bytes as f32),
3107
+ );
3108
+ let upload_generation_id = resume.upload_generation_id.clone();
3109
+ (
3110
+ NativeUploadMode::Streaming,
3111
+ Some(resume),
3112
+ upload_generation_id,
3113
+ )
3114
+ }
3115
+ NativeRetryUploadPlan::Restart {
3116
+ attempt_id,
3117
+ upload_generation_id,
3118
+ } => {
3119
+ emit_native_upload_progress(
3120
+ &app,
3121
+ "uploading",
3122
+ "Restarting upload",
3123
+ None,
3124
+ Some(0.0),
3125
+ );
3126
+ let reset = match reset_upload_chunks(
3127
+ &saved.server_url,
3128
+ &saved.recording_id,
3129
+ &prepared.mime_type,
3130
+ attempt_id.as_deref(),
3131
+ upload_generation_id.as_deref(),
3132
+ &auth_token,
3133
+ &cookie,
3134
+ )
3135
+ .await
3136
+ {
3137
+ Ok(reset) => reset,
3138
+ Err(err) => {
3139
+ interrupt_native_retry_upload(
3140
+ &saved.server_url,
3141
+ &saved.recording_id,
3142
+ &err,
3143
+ active_attempt_id.as_deref(),
3144
+ active_upload_generation_id.as_deref(),
3145
+ &auth_token,
3146
+ &cookie,
3147
+ )
3148
+ .await;
3149
+ return Err(err);
3150
+ }
3151
+ };
3152
+ (reset.mode(), None, reset.upload_generation_id)
3153
+ }
3154
+ NativeRetryUploadPlan::Reconcile => unreachable!("handled above"),
3155
+ };
3156
+
2993
3157
  let upload_result = upload_prepared_recording_file(
2994
3158
  &app,
2995
3159
  &prepared,
2996
3160
  saved.server_url.clone(),
2997
3161
  saved.recording_id.clone(),
2998
- auth_token.unwrap_or_default(),
2999
- cookie.unwrap_or_default(),
3162
+ auth_token.clone(),
3163
+ cookie.clone(),
3000
3164
  upload_mode,
3001
3165
  saved.duration_ms,
3002
3166
  saved.width,
3003
3167
  saved.height,
3004
3168
  saved.has_audio,
3005
3169
  saved.has_camera,
3170
+ active_attempt_id.clone(),
3171
+ upload_generation_id.clone(),
3172
+ streaming_resume,
3006
3173
  )
3007
3174
  .await;
3175
+ let replay_attempt_id = native_replay_attempt_id(
3176
+ &upload_result,
3177
+ active_attempt_id.as_deref(),
3178
+ );
3179
+ let replay_upload_generation_id = replay_attempt_id
3180
+ .as_ref()
3181
+ .and_then(|_| upload_generation_id.clone());
3182
+ let mut interruption_upload_generation_id = upload_generation_id.clone();
3183
+ let upload_result = if native_upload_restart_required(&upload_result) {
3184
+ eprintln!(
3185
+ "[clips-tray] native retry replaying from byte zero after the provider requested a restart"
3186
+ );
3187
+ match reset_upload_chunks(
3188
+ &saved.server_url,
3189
+ &saved.recording_id,
3190
+ &prepared.mime_type,
3191
+ replay_attempt_id.as_deref(),
3192
+ replay_upload_generation_id.as_deref(),
3193
+ &auth_token,
3194
+ &cookie,
3195
+ )
3196
+ .await
3197
+ {
3198
+ Ok(reset) => {
3199
+ interruption_upload_generation_id = reset.upload_generation_id.clone();
3200
+ upload_prepared_recording_file(
3201
+ &app,
3202
+ &prepared,
3203
+ saved.server_url.clone(),
3204
+ saved.recording_id.clone(),
3205
+ auth_token.clone(),
3206
+ cookie.clone(),
3207
+ reset.mode(),
3208
+ saved.duration_ms,
3209
+ saved.width,
3210
+ saved.height,
3211
+ saved.has_audio,
3212
+ saved.has_camera,
3213
+ replay_attempt_id.clone(),
3214
+ reset.upload_generation_id,
3215
+ None,
3216
+ )
3217
+ .await
3218
+ }
3219
+ Err(err) => Err(err),
3220
+ }
3221
+ } else {
3222
+ upload_result
3223
+ };
3224
+ if let Err(err) = &upload_result {
3225
+ interrupt_native_retry_upload(
3226
+ &saved.server_url,
3227
+ &saved.recording_id,
3228
+ err,
3229
+ replay_attempt_id.as_deref(),
3230
+ interruption_upload_generation_id.as_deref(),
3231
+ &auth_token,
3232
+ &cookie,
3233
+ )
3234
+ .await;
3235
+ }
3008
3236
  cleanup_prepared_saved_recording_files(&prepared, retry_combined_path);
3009
3237
  upload_result
3010
3238
  }
@@ -3534,6 +3762,7 @@ fn saved_recording_from_path(
3534
3762
  last_attempt_at: None,
3535
3763
  last_error: None,
3536
3764
  retry_count: 0,
3765
+ retry_attempt_id: None,
3537
3766
  custom_pipeline: session.custom_pipeline,
3538
3767
  corrupt: false,
3539
3768
  })
@@ -3645,6 +3874,7 @@ pub(crate) fn persist_shared_clip_recording(
3645
3874
  last_attempt_at: error.map(|_| now_iso()),
3646
3875
  last_error: error.map(ToString::to_string),
3647
3876
  retry_count: u32::from(error.is_some()),
3877
+ retry_attempt_id: None,
3648
3878
  custom_pipeline: true,
3649
3879
  corrupt: false,
3650
3880
  };
@@ -4236,6 +4466,9 @@ pub(crate) async fn upload_finalized_native_artifact(
4236
4466
  artifact.height,
4237
4467
  has_audio,
4238
4468
  has_camera,
4469
+ None,
4470
+ None,
4471
+ None,
4239
4472
  )
4240
4473
  .await;
4241
4474
  if prepared.temporary {
@@ -4392,6 +4625,9 @@ async fn upload_prepared_recording_file(
4392
4625
  height: Option<u32>,
4393
4626
  has_audio: bool,
4394
4627
  has_camera: bool,
4628
+ upload_attempt_id: Option<String>,
4629
+ upload_generation_id: Option<String>,
4630
+ streaming_resume: Option<NativeStreamingResume>,
4395
4631
  ) -> Result<NativeFullscreenUploadResult, String> {
4396
4632
  #[cfg(target_os = "macos")]
4397
4633
  let verified_local_duration_ms = {
@@ -4417,7 +4653,21 @@ async fn upload_prepared_recording_file(
4417
4653
  } else {
4418
4654
  total_chunks + 1
4419
4655
  };
4420
- emit_native_upload_progress(app, "uploading", "Uploading clip", None, Some(0.0));
4656
+ let resumed_bytes = streaming_resume
4657
+ .as_ref()
4658
+ .map(|resume| resume.bytes_received)
4659
+ .unwrap_or(0);
4660
+ emit_native_upload_progress(
4661
+ app,
4662
+ "uploading",
4663
+ if streaming_resume.is_some() {
4664
+ "Resuming upload"
4665
+ } else {
4666
+ "Uploading clip"
4667
+ },
4668
+ None,
4669
+ Some(resumed_bytes as f32 / total_bytes as f32),
4670
+ );
4421
4671
  eprintln!(
4422
4672
  "[clips-tray] native upload starting recording={recording_id} mode={} bytes={total_bytes} posts={total_posts}",
4423
4673
  upload_mode.label()
@@ -4429,16 +4679,26 @@ async fn upload_prepared_recording_file(
4429
4679
  let mut file =
4430
4680
  File::open(&prepared.path).map_err(|e| format!("native recording open failed: {e}"))?;
4431
4681
  let verification_pending;
4682
+ let upload_attempt_id = upload_attempt_id.as_deref();
4683
+ let upload_generation_id = upload_generation_id.as_deref();
4432
4684
 
4433
4685
  if upload_mode == NativeUploadMode::Streaming {
4434
4686
  // Resumable providers require every non-final body to be aligned. The
4435
4687
  // final body may be the unaligned tail; if the file is exactly aligned,
4436
4688
  // an empty final request closes the session.
4437
- for index in 0..streaming_full_chunks {
4689
+ let resume = streaming_resume.unwrap_or(NativeStreamingResume {
4690
+ bytes_received: 0,
4691
+ next_chunk_index: 0,
4692
+ attempt_id: None,
4693
+ upload_generation_id: None,
4694
+ });
4695
+ file.seek(SeekFrom::Start(resume.bytes_received))
4696
+ .map_err(|e| format!("native recording seek failed: {e}"))?;
4697
+ for index in resume.next_chunk_index..streaming_full_chunks {
4438
4698
  let mut buffer = vec![0_u8; UPLOAD_CHUNK_BYTES];
4439
4699
  file.read_exact(&mut buffer)
4440
4700
  .map_err(|e| format!("native recording read failed: {e}"))?;
4441
- send_upload_post(
4701
+ send_upload_post_with_attempt(
4442
4702
  &client,
4443
4703
  &server_url,
4444
4704
  &recording_id,
@@ -4456,6 +4716,8 @@ async fn upload_prepared_recording_file(
4456
4716
  upload_mode,
4457
4717
  false,
4458
4718
  None,
4719
+ upload_attempt_id,
4720
+ upload_generation_id,
4459
4721
  buffer,
4460
4722
  )
4461
4723
  .await?;
@@ -4481,7 +4743,7 @@ async fn upload_prepared_recording_file(
4481
4743
  None,
4482
4744
  Some(streaming_full_chunks as f32 / total_posts as f32),
4483
4745
  );
4484
- verification_pending = send_upload_post(
4746
+ verification_pending = send_upload_post_with_attempt(
4485
4747
  &client,
4486
4748
  &server_url,
4487
4749
  &recording_id,
@@ -4499,6 +4761,8 @@ async fn upload_prepared_recording_file(
4499
4761
  upload_mode,
4500
4762
  prepared.locally_transcoded,
4501
4763
  Some(total_bytes),
4764
+ upload_attempt_id,
4765
+ upload_generation_id,
4502
4766
  final_body,
4503
4767
  )
4504
4768
  .await?;
@@ -4512,7 +4776,7 @@ async fn upload_prepared_recording_file(
4512
4776
  return Err("Native recording ended before all chunks were read.".into());
4513
4777
  }
4514
4778
  buffer.truncate(read);
4515
- send_upload_post(
4779
+ send_upload_post_with_attempt(
4516
4780
  &client,
4517
4781
  &server_url,
4518
4782
  &recording_id,
@@ -4530,6 +4794,8 @@ async fn upload_prepared_recording_file(
4530
4794
  upload_mode,
4531
4795
  false,
4532
4796
  None,
4797
+ upload_attempt_id,
4798
+ upload_generation_id,
4533
4799
  buffer,
4534
4800
  )
4535
4801
  .await?;
@@ -4549,7 +4815,7 @@ async fn upload_prepared_recording_file(
4549
4815
  None,
4550
4816
  Some(total_chunks as f32 / total_posts as f32),
4551
4817
  );
4552
- verification_pending = send_upload_post(
4818
+ verification_pending = send_upload_post_with_attempt(
4553
4819
  &client,
4554
4820
  &server_url,
4555
4821
  &recording_id,
@@ -4567,6 +4833,8 @@ async fn upload_prepared_recording_file(
4567
4833
  upload_mode,
4568
4834
  prepared.locally_transcoded,
4569
4835
  Some(total_bytes),
4836
+ upload_attempt_id,
4837
+ upload_generation_id,
4570
4838
  Vec::new(),
4571
4839
  )
4572
4840
  .await?;
@@ -4583,13 +4851,470 @@ async fn upload_prepared_recording_file(
4583
4851
  })
4584
4852
  }
4585
4853
 
4854
+ async fn get_native_retry_upload_plan(
4855
+ server_url: &str,
4856
+ recording_id: &str,
4857
+ local_bytes: u64,
4858
+ exact_local_stream: bool,
4859
+ claimed_attempt_id: &str,
4860
+ auth_token: &str,
4861
+ cookie: &str,
4862
+ ) -> Result<NativeRetryUploadPlan, String> {
4863
+ let base = server_url.trim_end_matches('/');
4864
+ let mut url = url::Url::parse(&format!("{base}/api/uploads/{recording_id}/resume"))
4865
+ .map_err(|e| format!("invalid upload resume URL: {e}"))?;
4866
+ url.query_pairs_mut()
4867
+ .append_pair("attemptId", claimed_attempt_id);
4868
+ let client = reqwest::Client::builder()
4869
+ .timeout(Duration::from_secs(30))
4870
+ .build()
4871
+ .map_err(|e| format!("upload resume client failed: {e}"))?;
4872
+ let mut request = client
4873
+ .get(url)
4874
+ .header("X-Request-Source", "clips-desktop")
4875
+ .header("Accept", "application/json");
4876
+ if !auth_token.trim().is_empty() {
4877
+ request = request.bearer_auth(auth_token.trim());
4878
+ }
4879
+ if !cookie.trim().is_empty() {
4880
+ request = request.header("Cookie", cookie.trim());
4881
+ }
4882
+ let response = request
4883
+ .send()
4884
+ .await
4885
+ .map_err(|e| format!("native recording resume check failed: {e}"))?;
4886
+ let status = response.status();
4887
+ let body = response.text().await.unwrap_or_default();
4888
+ if !status.is_success() {
4889
+ return Err(format!(
4890
+ "native recording resume check returned {status}: {}",
4891
+ body.chars().take(400).collect::<String>()
4892
+ ));
4893
+ }
4894
+ let response: NativeUploadResumeResponse = serde_json::from_str(&body)
4895
+ .map_err(|_| "native recording resume check returned an unreadable response".to_string())?;
4896
+ if response.resumable && response.attempt_id.as_deref() != Some(claimed_attempt_id) {
4897
+ return Err(
4898
+ "native recording resume check did not acknowledge its attempt claim".to_string(),
4899
+ );
4900
+ }
4901
+ Ok(plan_native_retry_upload(
4902
+ response,
4903
+ local_bytes,
4904
+ exact_local_stream,
4905
+ ))
4906
+ }
4907
+
4908
+ #[derive(Deserialize)]
4909
+ #[serde(rename_all = "camelCase")]
4910
+ struct NativeUploadErrorReceipt {
4911
+ restart_required: Option<bool>,
4912
+ recovery_enabled: Option<bool>,
4913
+ }
4914
+
4915
+ fn is_native_upload_restart_required(status: reqwest::StatusCode, body: &str) -> bool {
4916
+ status == reqwest::StatusCode::CONFLICT
4917
+ && serde_json::from_str::<NativeUploadErrorReceipt>(body)
4918
+ .ok()
4919
+ .and_then(|receipt| receipt.restart_required)
4920
+ == Some(true)
4921
+ }
4922
+
4923
+ fn is_native_upload_unfenced_restart_required(status: reqwest::StatusCode, body: &str) -> bool {
4924
+ status == reqwest::StatusCode::CONFLICT
4925
+ && serde_json::from_str::<NativeUploadErrorReceipt>(body)
4926
+ .ok()
4927
+ .is_some_and(|receipt| {
4928
+ receipt.restart_required == Some(true) && receipt.recovery_enabled == Some(false)
4929
+ })
4930
+ }
4931
+
4932
+ fn native_upload_restart_required(result: &Result<NativeFullscreenUploadResult, String>) -> bool {
4933
+ matches!(result, Err(error) if error == NATIVE_UPLOAD_RESTART_REQUIRED || error == NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED)
4934
+ }
4935
+
4936
+ fn native_replay_attempt_id(
4937
+ result: &Result<NativeFullscreenUploadResult, String>,
4938
+ active_attempt_id: Option<&str>,
4939
+ ) -> Option<String> {
4940
+ if matches!(result, Err(error) if error == NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED) {
4941
+ None
4942
+ } else {
4943
+ active_attempt_id.map(str::to_string)
4944
+ }
4945
+ }
4946
+
4947
+ fn native_retry_attempt_id() -> String {
4948
+ let counter = NATIVE_RETRY_CLAIM_COUNTER.fetch_add(1, Ordering::Relaxed);
4949
+ format!("native-{:x}-{:x}", Utc::now().timestamp_millis(), counter)
4950
+ }
4951
+
4952
+ fn saved_native_retry_attempt_id(attempt_id: &mut Option<String>) -> String {
4953
+ if let Some(existing) = attempt_id
4954
+ .as_deref()
4955
+ .filter(|existing| !existing.trim().is_empty())
4956
+ {
4957
+ return existing.to_string();
4958
+ }
4959
+ let claimed_attempt_id = native_retry_attempt_id();
4960
+ *attempt_id = Some(claimed_attempt_id.clone());
4961
+ claimed_attempt_id
4962
+ }
4963
+
4964
+ fn plan_native_retry_upload(
4965
+ response: NativeUploadResumeResponse,
4966
+ local_bytes: u64,
4967
+ exact_local_stream: bool,
4968
+ ) -> NativeRetryUploadPlan {
4969
+ if !response.recovery_enabled {
4970
+ return NativeRetryUploadPlan::Restart {
4971
+ attempt_id: None,
4972
+ upload_generation_id: None,
4973
+ };
4974
+ }
4975
+
4976
+ match response.status.as_deref() {
4977
+ Some("ready") | Some("processing") => {
4978
+ return NativeRetryUploadPlan::Reconcile;
4979
+ }
4980
+ _ => {}
4981
+ }
4982
+
4983
+ let Some(bytes_received) = response.bytes_received else {
4984
+ return NativeRetryUploadPlan::Restart {
4985
+ attempt_id: response.attempt_id,
4986
+ upload_generation_id: response.upload_generation_id,
4987
+ };
4988
+ };
4989
+ let Some(next_chunk_index) = response.next_chunk_index else {
4990
+ return NativeRetryUploadPlan::Restart {
4991
+ attempt_id: response.attempt_id,
4992
+ upload_generation_id: response.upload_generation_id,
4993
+ };
4994
+ };
4995
+ let Ok(next_chunk_index) = usize::try_from(next_chunk_index) else {
4996
+ return NativeRetryUploadPlan::Restart {
4997
+ attempt_id: response.attempt_id,
4998
+ upload_generation_id: response.upload_generation_id,
4999
+ };
5000
+ };
5001
+ let aligned = bytes_received % UPLOAD_CHUNK_BYTES as u64 == 0;
5002
+ let consistent_index = next_chunk_index as u64 == bytes_received / UPLOAD_CHUNK_BYTES as u64;
5003
+ if response.resumable
5004
+ && response.upload_mode.as_deref() == Some("streaming")
5005
+ && exact_local_stream
5006
+ && bytes_received <= local_bytes
5007
+ && aligned
5008
+ && consistent_index
5009
+ {
5010
+ return NativeRetryUploadPlan::Resume(NativeStreamingResume {
5011
+ bytes_received,
5012
+ next_chunk_index,
5013
+ attempt_id: response.attempt_id,
5014
+ upload_generation_id: response.upload_generation_id,
5015
+ });
5016
+ }
5017
+
5018
+ NativeRetryUploadPlan::Restart {
5019
+ attempt_id: response.attempt_id,
5020
+ upload_generation_id: response.upload_generation_id,
5021
+ }
5022
+ }
5023
+
5024
+ async fn interrupt_native_retry_upload(
5025
+ server_url: &str,
5026
+ recording_id: &str,
5027
+ detail: &str,
5028
+ attempt_id: Option<&str>,
5029
+ upload_generation_id: Option<&str>,
5030
+ auth_token: &str,
5031
+ cookie: &str,
5032
+ ) {
5033
+ let base = server_url.trim_end_matches('/');
5034
+ let Ok(url) = url::Url::parse(&format!("{base}/api/uploads/{recording_id}/interrupt")) else {
5035
+ return;
5036
+ };
5037
+ let Ok(client) = reqwest::Client::builder()
5038
+ .timeout(Duration::from_secs(30))
5039
+ .build()
5040
+ else {
5041
+ return;
5042
+ };
5043
+ let mut request = client
5044
+ .post(url)
5045
+ .header("Content-Type", "application/json")
5046
+ .header("X-Request-Source", "clips-desktop")
5047
+ .json(&native_retry_interruption_payload(
5048
+ detail,
5049
+ attempt_id,
5050
+ upload_generation_id,
5051
+ ));
5052
+ if !auth_token.trim().is_empty() {
5053
+ request = request.bearer_auth(auth_token.trim());
5054
+ }
5055
+ if !cookie.trim().is_empty() {
5056
+ request = request.header("Cookie", cookie.trim());
5057
+ }
5058
+ match request.send().await {
5059
+ Ok(response) if response.status().is_success() => {
5060
+ eprintln!("[clips-tray] native retry interruption recorded for {recording_id}");
5061
+ }
5062
+ Ok(response) => {
5063
+ eprintln!(
5064
+ "[clips-tray] native retry interruption rejected for {recording_id}: {}",
5065
+ response.status()
5066
+ );
5067
+ }
5068
+ Err(err) => {
5069
+ eprintln!(
5070
+ "[clips-tray] native retry interruption could not be recorded for {recording_id}: {err}"
5071
+ );
5072
+ }
5073
+ }
5074
+ }
5075
+
5076
+ fn native_retry_interruption_payload(
5077
+ detail: &str,
5078
+ attempt_id: Option<&str>,
5079
+ upload_generation_id: Option<&str>,
5080
+ ) -> serde_json::Value {
5081
+ serde_json::json!({
5082
+ "detail": detail.chars().take(1000).collect::<String>(),
5083
+ "attemptId": attempt_id.filter(|value| !value.trim().is_empty()),
5084
+ "uploadGenerationId": upload_generation_id.filter(|value| !value.trim().is_empty()),
5085
+ })
5086
+ }
5087
+
5088
+ #[cfg(test)]
5089
+ mod native_retry_upload_plan_tests {
5090
+ use super::{
5091
+ is_native_upload_restart_required, is_native_upload_unfenced_restart_required,
5092
+ native_replay_attempt_id, native_retry_attempt_id, native_retry_interruption_payload,
5093
+ plan_native_retry_upload, saved_native_retry_attempt_id, upload_url,
5094
+ NativeFullscreenUploadResult, NativeRetryUploadPlan, NativeUploadResumeResponse,
5095
+ NATIVE_UPLOAD_RESTART_REQUIRED, NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED,
5096
+ UPLOAD_CHUNK_BYTES,
5097
+ };
5098
+
5099
+ fn response(bytes_received: u64, next_chunk_index: u64) -> NativeUploadResumeResponse {
5100
+ NativeUploadResumeResponse {
5101
+ resumable: true,
5102
+ recovery_enabled: true,
5103
+ status: Some("uploading".to_string()),
5104
+ upload_mode: Some("streaming".to_string()),
5105
+ bytes_received: Some(bytes_received),
5106
+ next_chunk_index: Some(next_chunk_index),
5107
+ attempt_id: Some("attempt-1".to_string()),
5108
+ upload_generation_id: Some("generation-1".to_string()),
5109
+ }
5110
+ }
5111
+
5112
+ #[test]
5113
+ fn resumes_only_an_aligned_prefix_of_the_exact_local_stream() {
5114
+ let plan = plan_native_retry_upload(
5115
+ response((UPLOAD_CHUNK_BYTES * 2) as u64, 2),
5116
+ (UPLOAD_CHUNK_BYTES * 3 + 1) as u64,
5117
+ true,
5118
+ );
5119
+ assert!(matches!(
5120
+ plan,
5121
+ NativeRetryUploadPlan::Resume(resume)
5122
+ if resume.bytes_received == (UPLOAD_CHUNK_BYTES * 2) as u64
5123
+ && resume.next_chunk_index == 2
5124
+ && resume.attempt_id.as_deref() == Some("attempt-1")
5125
+ ));
5126
+ }
5127
+
5128
+ #[test]
5129
+ fn restarts_for_a_transcoded_or_contradictory_resume_response() {
5130
+ let transcoded = plan_native_retry_upload(
5131
+ response(UPLOAD_CHUNK_BYTES as u64, 1),
5132
+ (UPLOAD_CHUNK_BYTES * 2) as u64,
5133
+ false,
5134
+ );
5135
+ assert!(matches!(transcoded, NativeRetryUploadPlan::Restart { .. }));
5136
+
5137
+ let contradictory = plan_native_retry_upload(
5138
+ response(UPLOAD_CHUNK_BYTES as u64, 0),
5139
+ (UPLOAD_CHUNK_BYTES * 2) as u64,
5140
+ true,
5141
+ );
5142
+ assert!(matches!(
5143
+ contradictory,
5144
+ NativeRetryUploadPlan::Restart { .. }
5145
+ ));
5146
+ }
5147
+
5148
+ #[test]
5149
+ fn restarts_without_a_claim_when_resumable_retry_is_disabled() {
5150
+ let plan = plan_native_retry_upload(
5151
+ NativeUploadResumeResponse {
5152
+ resumable: false,
5153
+ recovery_enabled: false,
5154
+ status: None,
5155
+ upload_mode: None,
5156
+ bytes_received: None,
5157
+ next_chunk_index: None,
5158
+ attempt_id: Some("ignored-attempt".to_string()),
5159
+ upload_generation_id: Some("ignored-generation".to_string()),
5160
+ },
5161
+ UPLOAD_CHUNK_BYTES as u64,
5162
+ true,
5163
+ );
5164
+
5165
+ assert!(matches!(
5166
+ plan,
5167
+ NativeRetryUploadPlan::Restart {
5168
+ attempt_id: None,
5169
+ ..
5170
+ }
5171
+ ));
5172
+ }
5173
+
5174
+ #[test]
5175
+ fn reconciles_terminal_resume_without_an_attempt_echo() {
5176
+ let terminal = plan_native_retry_upload(
5177
+ NativeUploadResumeResponse {
5178
+ resumable: false,
5179
+ recovery_enabled: true,
5180
+ status: Some("ready".to_string()),
5181
+ upload_mode: None,
5182
+ bytes_received: None,
5183
+ next_chunk_index: None,
5184
+ attempt_id: None,
5185
+ upload_generation_id: None,
5186
+ },
5187
+ UPLOAD_CHUNK_BYTES as u64,
5188
+ true,
5189
+ );
5190
+ assert!(matches!(terminal, NativeRetryUploadPlan::Reconcile));
5191
+ }
5192
+
5193
+ #[test]
5194
+ fn claimed_restart_carries_its_attempt_id_to_buffered_posts() {
5195
+ let claimed_attempt_id = native_retry_attempt_id();
5196
+ assert!(claimed_attempt_id.len() >= 16);
5197
+ assert!(claimed_attempt_id
5198
+ .bytes()
5199
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
5200
+
5201
+ let restart = plan_native_retry_upload(
5202
+ NativeUploadResumeResponse {
5203
+ resumable: true,
5204
+ recovery_enabled: true,
5205
+ status: Some("uploading".to_string()),
5206
+ upload_mode: Some("buffered".to_string()),
5207
+ bytes_received: Some(0),
5208
+ next_chunk_index: Some(0),
5209
+ attempt_id: Some(claimed_attempt_id.clone()),
5210
+ upload_generation_id: Some("generation-1".to_string()),
5211
+ },
5212
+ UPLOAD_CHUNK_BYTES as u64,
5213
+ true,
5214
+ );
5215
+ assert!(matches!(
5216
+ restart,
5217
+ NativeRetryUploadPlan::Restart { ref attempt_id, .. }
5218
+ if attempt_id.as_deref() == Some(claimed_attempt_id.as_str())
5219
+ ));
5220
+
5221
+ let url = upload_url(
5222
+ "https://clips.example",
5223
+ "recording-1",
5224
+ 0,
5225
+ 2,
5226
+ false,
5227
+ None,
5228
+ "video/mp4",
5229
+ None,
5230
+ None,
5231
+ true,
5232
+ false,
5233
+ false,
5234
+ Some(&claimed_attempt_id),
5235
+ Some("generation-1"),
5236
+ )
5237
+ .expect("buffered upload URL");
5238
+ let query = url::Url::parse(&url)
5239
+ .expect("valid buffered upload URL")
5240
+ .query_pairs()
5241
+ .find(|(key, _)| key == "attemptId")
5242
+ .map(|(_, value)| value.into_owned());
5243
+ assert_eq!(query.as_deref(), Some(claimed_attempt_id.as_str()));
5244
+ }
5245
+
5246
+ #[test]
5247
+ fn persists_the_same_retry_claim_across_later_retries() {
5248
+ let mut saved_attempt_id = None;
5249
+ let first = saved_native_retry_attempt_id(&mut saved_attempt_id);
5250
+ let later = saved_native_retry_attempt_id(&mut saved_attempt_id);
5251
+ assert_eq!(first, later);
5252
+ assert_eq!(saved_attempt_id.as_deref(), Some(first.as_str()));
5253
+ }
5254
+
5255
+ #[test]
5256
+ fn reports_retry_interruptions_with_or_without_a_fencing_claim() {
5257
+ let unfenced = native_retry_interruption_payload("upload failed", None, None);
5258
+ assert_eq!(unfenced["detail"], "upload failed");
5259
+ assert!(unfenced["attemptId"].is_null());
5260
+
5261
+ let fenced = native_retry_interruption_payload(
5262
+ "upload failed",
5263
+ Some("attempt-1"),
5264
+ Some("generation-1"),
5265
+ );
5266
+ assert_eq!(fenced["attemptId"], "attempt-1");
5267
+ assert_eq!(fenced["uploadGenerationId"], "generation-1");
5268
+ }
5269
+
5270
+ #[test]
5271
+ fn recognizes_only_the_structured_conflict_restart_signal() {
5272
+ assert!(is_native_upload_restart_required(
5273
+ reqwest::StatusCode::CONFLICT,
5274
+ r#"{"restartRequired":true}"#,
5275
+ ));
5276
+ assert!(!is_native_upload_restart_required(
5277
+ reqwest::StatusCode::CONFLICT,
5278
+ r#"{"restartRequired":false}"#,
5279
+ ));
5280
+ assert!(!is_native_upload_restart_required(
5281
+ reqwest::StatusCode::BAD_REQUEST,
5282
+ r#"{"restartRequired":true}"#,
5283
+ ));
5284
+ assert!(is_native_upload_unfenced_restart_required(
5285
+ reqwest::StatusCode::CONFLICT,
5286
+ r#"{"restartRequired":true,"recoveryEnabled":false}"#,
5287
+ ));
5288
+ assert!(!is_native_upload_unfenced_restart_required(
5289
+ reqwest::StatusCode::CONFLICT,
5290
+ r#"{"restartRequired":true}"#,
5291
+ ));
5292
+ }
5293
+
5294
+ #[test]
5295
+ fn drops_the_retry_claim_for_a_feature_disabled_replay() {
5296
+ let disabled: Result<NativeFullscreenUploadResult, String> =
5297
+ Err(NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED.to_string());
5298
+ assert_eq!(native_replay_attempt_id(&disabled, Some("attempt-1")), None);
5299
+
5300
+ let expired: Result<NativeFullscreenUploadResult, String> =
5301
+ Err(NATIVE_UPLOAD_RESTART_REQUIRED.to_string());
5302
+ assert_eq!(
5303
+ native_replay_attempt_id(&expired, Some("attempt-1")).as_deref(),
5304
+ Some("attempt-1")
5305
+ );
5306
+ }
5307
+ }
5308
+
4586
5309
  async fn reset_upload_chunks(
4587
5310
  server_url: &str,
4588
5311
  recording_id: &str,
4589
5312
  mime_type: &str,
5313
+ attempt_id: Option<&str>,
5314
+ upload_generation_id: Option<&str>,
4590
5315
  auth_token: &str,
4591
5316
  cookie: &str,
4592
- ) -> Result<NativeUploadMode, String> {
5317
+ ) -> Result<NativeUploadResetResponse, String> {
4593
5318
  let base = server_url.trim_end_matches('/');
4594
5319
  let url = url::Url::parse(&format!("{base}/api/uploads/{recording_id}/reset-chunks"))
4595
5320
  .map_err(|e| format!("invalid reset URL: {e}"))?;
@@ -4604,6 +5329,8 @@ async fn reset_upload_chunks(
4604
5329
  .json(&serde_json::json!({
4605
5330
  "requestStreaming": true,
4606
5331
  "mimeType": mime_type,
5332
+ "attemptId": attempt_id,
5333
+ "uploadGenerationId": upload_generation_id,
4607
5334
  }));
4608
5335
  let trimmed_token = auth_token.trim();
4609
5336
  if !trimmed_token.is_empty() {
@@ -4626,7 +5353,12 @@ async fn reset_upload_chunks(
4626
5353
  body.chars().take(400).collect::<String>()
4627
5354
  ));
4628
5355
  }
4629
- Ok(NativeUploadMode::from_reset_response(&body))
5356
+ let reset: NativeUploadResetResponse = serde_json::from_str(&body)
5357
+ .map_err(|_| "native recording retry setup returned an unreadable response".to_string())?;
5358
+ if attempt_id.is_some() && reset.upload_generation_id.is_none() {
5359
+ return Err("native recording retry setup returned no upload generation".to_string());
5360
+ }
5361
+ Ok(reset)
4630
5362
  }
4631
5363
 
4632
5364
  async fn send_upload_post(
@@ -4648,6 +5380,53 @@ async fn send_upload_post(
4648
5380
  locally_transcoded: bool,
4649
5381
  expected_source_bytes: Option<u64>,
4650
5382
  body: Vec<u8>,
5383
+ ) -> Result<bool, String> {
5384
+ send_upload_post_with_attempt(
5385
+ client,
5386
+ server_url,
5387
+ recording_id,
5388
+ auth_token,
5389
+ cookie,
5390
+ index,
5391
+ total,
5392
+ is_final,
5393
+ duration_ms,
5394
+ mime_type,
5395
+ width,
5396
+ height,
5397
+ has_audio,
5398
+ has_camera,
5399
+ upload_mode,
5400
+ locally_transcoded,
5401
+ expected_source_bytes,
5402
+ None,
5403
+ None,
5404
+ body,
5405
+ )
5406
+ .await
5407
+ }
5408
+
5409
+ async fn send_upload_post_with_attempt(
5410
+ client: &reqwest::Client,
5411
+ server_url: &str,
5412
+ recording_id: &str,
5413
+ auth_token: &str,
5414
+ cookie: &str,
5415
+ index: usize,
5416
+ total: usize,
5417
+ is_final: bool,
5418
+ duration_ms: Option<u128>,
5419
+ mime_type: &str,
5420
+ width: Option<u32>,
5421
+ height: Option<u32>,
5422
+ has_audio: bool,
5423
+ has_camera: bool,
5424
+ upload_mode: NativeUploadMode,
5425
+ locally_transcoded: bool,
5426
+ expected_source_bytes: Option<u64>,
5427
+ attempt_id: Option<&str>,
5428
+ upload_generation_id: Option<&str>,
5429
+ body: Vec<u8>,
4651
5430
  ) -> Result<bool, String> {
4652
5431
  let body_len = body.len();
4653
5432
  let url = upload_url(
@@ -4663,6 +5442,8 @@ async fn send_upload_post(
4663
5442
  has_audio,
4664
5443
  has_camera,
4665
5444
  locally_transcoded,
5445
+ attempt_id,
5446
+ upload_generation_id,
4666
5447
  )?;
4667
5448
  eprintln!(
4668
5449
  "[clips-tray] native upload post start recording={recording_id} mode={} index={index}/{total} final={is_final} bytes={body_len}",
@@ -4692,6 +5473,15 @@ async fn send_upload_post(
4692
5473
  "[live-upload] POST chunk #{index} (final={is_final}) -> {status} for {recording_id}"
4693
5474
  );
4694
5475
  if !status.is_success() {
5476
+ if is_native_upload_restart_required(status, &body) {
5477
+ return Err(
5478
+ if is_native_upload_unfenced_restart_required(status, &body) {
5479
+ NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED.to_string()
5480
+ } else {
5481
+ NATIVE_UPLOAD_RESTART_REQUIRED.to_string()
5482
+ },
5483
+ );
5484
+ }
4695
5485
  return Err(format!(
4696
5486
  "native recording upload returned {status}: {}",
4697
5487
  body.chars().take(400).collect::<String>()
@@ -4827,6 +5617,8 @@ fn upload_url(
4827
5617
  has_audio: bool,
4828
5618
  has_camera: bool,
4829
5619
  locally_transcoded: bool,
5620
+ attempt_id: Option<&str>,
5621
+ upload_generation_id: Option<&str>,
4830
5622
  ) -> Result<String, String> {
4831
5623
  let base = server_url.trim_end_matches('/');
4832
5624
  let mut url = url::Url::parse(&format!("{base}/api/uploads/{recording_id}/chunk"))
@@ -4852,6 +5644,14 @@ fn upload_url(
4852
5644
  if is_final && locally_transcoded {
4853
5645
  query.append_pair("locallyTranscoded", "1");
4854
5646
  }
5647
+ if let Some(attempt_id) = attempt_id.filter(|value| !value.trim().is_empty()) {
5648
+ query.append_pair("attemptId", attempt_id);
5649
+ }
5650
+ if let Some(upload_generation_id) =
5651
+ upload_generation_id.filter(|value| !value.trim().is_empty())
5652
+ {
5653
+ query.append_pair("uploadGenerationId", upload_generation_id);
5654
+ }
4855
5655
  }
4856
5656
  Ok(url.to_string())
4857
5657
  }