@agent-native/core 0.134.1 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (442) hide show
  1. package/corpus/README.md +3 -3
  2. package/corpus/core/CHANGELOG.md +107 -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/index.ts +5 -0
  48. package/corpus/core/src/sharing/recipients.ts +67 -0
  49. package/corpus/core/src/triggers/actions/list-automations.ts +27 -8
  50. package/corpus/core/src/triggers/actions/manage-automation.ts +17 -6
  51. package/corpus/core/src/triggers/actions.ts +16 -4
  52. package/corpus/core/src/triggers/dispatcher.ts +44 -30
  53. package/corpus/core/src/triggers/routes.ts +19 -6
  54. package/corpus/core/src/vitest-config.ts +68 -0
  55. package/corpus/core/vitest.config.ts +5 -0
  56. package/corpus/templates/analytics/app/pages/Settings.tsx +37 -53
  57. package/corpus/templates/analytics/changelog/2026-08-01-new-error-alerts-now-reach-you-by-email-and-a-scheduled-dash.md +6 -0
  58. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +43 -0
  59. package/corpus/templates/analytics/server/lib/error-capture.ts +5 -1
  60. package/corpus/templates/analytics/vitest.config.ts +16 -12
  61. package/corpus/templates/assets/actions/refresh-generation-run.ts +3 -0
  62. package/corpus/templates/assets/app/components/ui/switch.tsx +1 -0
  63. package/corpus/templates/assets/app/hooks/use-assets-prefs.ts +55 -0
  64. package/corpus/templates/assets/app/i18n-data.ts +30 -0
  65. package/corpus/templates/assets/app/routes/settings.tsx +58 -55
  66. package/corpus/templates/assets/changelog/2026-08-01-get-an-email-when-a-generation-you-started-finishes-or-fails.md +6 -0
  67. package/corpus/templates/assets/server/lib/generation-run-notifications.ts +89 -0
  68. package/corpus/templates/assets/server/lib/video-runs.ts +8 -1
  69. package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.get.ts +17 -0
  70. package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.put.ts +36 -0
  71. package/corpus/templates/assets/shared/assets-user-prefs.ts +10 -0
  72. package/corpus/templates/assets/vitest.config.ts +16 -12
  73. package/corpus/templates/brain/app/routes/settings.tsx +15 -15
  74. package/corpus/templates/brain/vitest.config.ts +16 -12
  75. package/corpus/templates/calendar/app/pages/Settings.tsx +32 -40
  76. package/corpus/templates/calendar/changelog/2026-08-01-language-and-appearance-now-sit-together-in-one-preferences-.md +6 -0
  77. package/corpus/templates/calendar/vitest.config.ts +16 -12
  78. package/corpus/templates/chat/app/routes/settings.tsx +14 -22
  79. package/corpus/templates/chat/vitest.config.ts +9 -0
  80. package/corpus/templates/clips/.agents/skills/video-editing/SKILL.md +31 -0
  81. package/corpus/templates/clips/AGENTS.md +1 -0
  82. package/corpus/templates/clips/actions/add-comment.ts +12 -1
  83. package/corpus/templates/clips/actions/create-meeting.ts +2 -2
  84. package/corpus/templates/clips/actions/create-recording.ts +2 -2
  85. package/corpus/templates/clips/actions/finalize-recording.ts +42 -6
  86. package/corpus/templates/clips/actions/generate-filmstrip.ts +154 -0
  87. package/corpus/templates/clips/actions/get-recording-player-data.ts +6 -0
  88. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -2
  89. package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +258 -0
  90. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +11 -3
  91. package/corpus/templates/clips/actions/react-to-comment.ts +13 -0
  92. package/corpus/templates/clips/actions/react-to-recording.ts +10 -1
  93. package/corpus/templates/clips/actions/reply-to-comment.ts +12 -1
  94. package/corpus/templates/clips/actions/stitch-recordings.ts +2 -2
  95. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +152 -23
  96. package/corpus/templates/clips/app/components/editor/waveform.tsx +237 -75
  97. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +23 -1
  98. package/corpus/templates/clips/app/components/settings/ai-setup-section.tsx +441 -0
  99. package/corpus/templates/clips/app/components/settings/slack-section.tsx +329 -0
  100. package/corpus/templates/clips/app/components/settings/types.ts +14 -0
  101. package/corpus/templates/clips/app/components/settings/video-storage-section.tsx +443 -0
  102. package/corpus/templates/clips/app/hooks/use-secret-status.ts +69 -0
  103. package/corpus/templates/clips/app/i18n/en-US.ts +9 -1
  104. package/corpus/templates/clips/app/lib/video-filmstrip.ts +262 -0
  105. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +165 -1351
  106. package/corpus/templates/clips/changelog/2026-07-27-saved-clips-now-resume-from-the-last-uploaded-byte-after-an-.md +6 -0
  107. package/corpus/templates/clips/changelog/2026-07-31-editor-timeline-now-shows-video-frames-behind-the-waveform-g.md +6 -0
  108. package/corpus/templates/clips/changelog/2026-07-31-email-notifications-now-go-out-when-someone-comments-on-or-r.md +6 -0
  109. package/corpus/templates/clips/changelog/2026-07-31-you-can-now-set-a-default-visibility-for-new-recordings-in-s.md +6 -0
  110. package/corpus/templates/clips/changelog/2026-08-01-email-notifications-for-comments-and-reactions-now-actually-.md +6 -0
  111. package/corpus/templates/clips/changelog/2026-08-03-the-desktop-app-now-opens-directly-beneath-its-tray-icon-on-.md +6 -0
  112. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  113. package/corpus/templates/clips/chrome-extension/vitest.config.ts +9 -0
  114. package/corpus/templates/clips/desktop/package.json +1 -0
  115. package/corpus/templates/clips/desktop/src/app.tsx +17 -1
  116. package/corpus/templates/clips/desktop/src/lib/recorder.ts +331 -36
  117. package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +129 -0
  118. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +815 -15
  119. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +61 -11
  120. package/corpus/templates/clips/desktop/vitest.config.ts +9 -0
  121. package/corpus/templates/clips/server/db/schema.ts +17 -0
  122. package/corpus/templates/clips/server/jobs/media-verification.ts +4 -0
  123. package/corpus/templates/clips/server/lib/activity-notifications.ts +185 -0
  124. package/corpus/templates/clips/server/lib/recording-media-cleanup.ts +2 -0
  125. package/corpus/templates/clips/server/lib/recording-upload-state.ts +46 -9
  126. package/corpus/templates/clips/server/lib/recordings.ts +24 -0
  127. package/corpus/templates/clips/server/lib/resumable-session.ts +10 -4
  128. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +109 -0
  129. package/corpus/templates/clips/server/lib/upload-interruption.ts +8 -0
  130. package/corpus/templates/clips/server/lib/upload-lease.ts +50 -4
  131. package/corpus/templates/clips/server/lib/video-filmstrip-sprite.ts +207 -0
  132. package/corpus/templates/clips/server/lib/video-remux.ts +13 -4
  133. package/corpus/templates/clips/server/plugins/db.ts +16 -0
  134. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +4 -0
  135. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +123 -36
  136. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +159 -29
  137. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +197 -0
  138. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +193 -39
  139. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +191 -12
  140. package/corpus/templates/clips/shared/clips-ai-prefs.ts +6 -0
  141. package/corpus/templates/clips/shared/feature-flags.ts +12 -0
  142. package/corpus/templates/clips/shared/recording-core.ts +4 -0
  143. package/corpus/templates/clips/vitest.config.ts +23 -19
  144. package/corpus/templates/content/actions/add-comment.ts +15 -1
  145. package/corpus/templates/content/actions/list-documents.ts +1 -0
  146. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +5 -2
  147. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +50 -1
  148. package/corpus/templates/content/app/components/ui/switch.tsx +1 -0
  149. package/corpus/templates/content/app/hooks/use-content-prefs.ts +55 -0
  150. package/corpus/templates/content/app/i18n-data.ts +40 -0
  151. package/corpus/templates/content/app/routes/_app.settings.tsx +47 -18
  152. package/corpus/templates/content/changelog/2026-07-30-breadcrumb-menus-now-hide-internal-system-pages.md +6 -0
  153. package/corpus/templates/content/changelog/2026-08-01-get-an-email-when-someone-comments-on-replies-in-or-mentions.md +6 -0
  154. package/corpus/templates/content/server/lib/comment-notifications.ts +146 -0
  155. package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.get.ts +17 -0
  156. package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.put.ts +36 -0
  157. package/corpus/templates/content/shared/content-user-prefs.ts +11 -0
  158. package/corpus/templates/content/vitest.config.ts +20 -16
  159. package/corpus/templates/crm/app/routes/settings.tsx +14 -22
  160. package/corpus/templates/crm/vitest.config.ts +8 -4
  161. package/corpus/templates/design/app/routes/settings.tsx +14 -22
  162. package/corpus/templates/design/changelog/2026-08-01-review-comments-replies-and-mentions-now-send-an-email-to-th.md +6 -0
  163. package/corpus/templates/design/vitest.config.ts +18 -12
  164. package/corpus/templates/dispatch/app/routes/settings.tsx +26 -40
  165. package/corpus/templates/dispatch/vitest.config.ts +11 -7
  166. package/corpus/templates/factory/app/routes/settings.tsx +14 -22
  167. package/corpus/templates/factory/vitest.config.ts +9 -0
  168. package/corpus/templates/forms/app/routes/_app.settings.tsx +14 -22
  169. package/corpus/templates/forms/vitest.config.ts +27 -23
  170. package/corpus/templates/macros/app/routes/settings.tsx +14 -22
  171. package/corpus/templates/macros/vitest.config.ts +16 -12
  172. package/corpus/templates/mail/app/pages/SettingsPage.tsx +14 -16
  173. package/corpus/templates/mail/vitest.config.ts +16 -12
  174. package/corpus/templates/plan/app/routes/settings.tsx +30 -44
  175. package/corpus/templates/plan/vitest.config.ts +24 -20
  176. package/corpus/templates/slides/actions/add-slide-comment.ts +12 -1
  177. package/corpus/templates/slides/app/components/ui/switch.tsx +1 -0
  178. package/corpus/templates/slides/app/hooks/use-slides-prefs.ts +55 -0
  179. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  180. package/corpus/templates/slides/app/routes/settings.tsx +45 -22
  181. package/corpus/templates/slides/changelog/2026-08-01-get-an-email-when-someone-comments-on-or-replies-in-your-dec.md +6 -0
  182. package/corpus/templates/slides/server/lib/comment-notifications.ts +182 -0
  183. package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.get.ts +16 -0
  184. package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.put.ts +36 -0
  185. package/corpus/templates/slides/shared/slides-user-prefs.ts +11 -0
  186. package/corpus/templates/slides/vitest.config.ts +17 -13
  187. package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +2 -0
  188. package/corpus/templates/tasks/app/routes/settings.tsx +14 -22
  189. package/corpus/templates/tasks/vitest.config.ts +17 -13
  190. package/corpus/toolkit/CHANGELOG.md +9 -0
  191. package/corpus/toolkit/package.json +1 -1
  192. package/corpus/toolkit/vitest.config.ts +3 -0
  193. package/dist/automations/service.d.ts +2 -0
  194. package/dist/automations/service.d.ts.map +1 -1
  195. package/dist/automations/service.js +29 -4
  196. package/dist/automations/service.js.map +1 -1
  197. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  198. package/dist/cli/mcp-config-writers.js +24 -23
  199. package/dist/cli/mcp-config-writers.js.map +1 -1
  200. package/dist/client/agent-page/AgentJobsTab.d.ts.map +1 -1
  201. package/dist/client/agent-page/AgentJobsTab.js +86 -29
  202. package/dist/client/agent-page/AgentJobsTab.js.map +1 -1
  203. package/dist/client/agent-page/AutomationDetailsDialog.d.ts +21 -0
  204. package/dist/client/agent-page/AutomationDetailsDialog.d.ts.map +1 -0
  205. package/dist/client/agent-page/AutomationDetailsDialog.js +33 -0
  206. package/dist/client/agent-page/AutomationDetailsDialog.js.map +1 -0
  207. package/dist/client/agent-page/AutomationScheduleDialog.d.ts +15 -0
  208. package/dist/client/agent-page/AutomationScheduleDialog.d.ts.map +1 -0
  209. package/dist/client/agent-page/AutomationScheduleDialog.js +46 -0
  210. package/dist/client/agent-page/AutomationScheduleDialog.js.map +1 -0
  211. package/dist/client/agent-page/TimezoneSelect.d.ts +17 -0
  212. package/dist/client/agent-page/TimezoneSelect.d.ts.map +1 -0
  213. package/dist/client/agent-page/TimezoneSelect.js +107 -0
  214. package/dist/client/agent-page/TimezoneSelect.js.map +1 -0
  215. package/dist/client/agent-page/use-jobs.d.ts +20 -7
  216. package/dist/client/agent-page/use-jobs.d.ts.map +1 -1
  217. package/dist/client/agent-page/use-jobs.js +24 -4
  218. package/dist/client/agent-page/use-jobs.js.map +1 -1
  219. package/dist/client/org/OrgSwitcher.d.ts +8 -1
  220. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  221. package/dist/client/org/OrgSwitcher.js +8 -2
  222. package/dist/client/org/OrgSwitcher.js.map +1 -1
  223. package/dist/client/settings/AccountSettingsCard.d.ts.map +1 -1
  224. package/dist/client/settings/AccountSettingsCard.js +10 -9
  225. package/dist/client/settings/AccountSettingsCard.js.map +1 -1
  226. package/dist/client/settings/SchedulingTimezoneField.d.ts +10 -0
  227. package/dist/client/settings/SchedulingTimezoneField.d.ts.map +1 -0
  228. package/dist/client/settings/SchedulingTimezoneField.js +30 -0
  229. package/dist/client/settings/SchedulingTimezoneField.js.map +1 -0
  230. package/dist/client/settings/SettingsRow.d.ts +36 -0
  231. package/dist/client/settings/SettingsRow.d.ts.map +1 -0
  232. package/dist/client/settings/SettingsRow.js +15 -0
  233. package/dist/client/settings/SettingsRow.js.map +1 -0
  234. package/dist/client/settings/agent-settings-search.d.ts.map +1 -1
  235. package/dist/client/settings/agent-settings-search.js +1 -1
  236. package/dist/client/settings/agent-settings-search.js.map +1 -1
  237. package/dist/client/settings/index.d.ts +1 -0
  238. package/dist/client/settings/index.d.ts.map +1 -1
  239. package/dist/client/settings/index.js +1 -0
  240. package/dist/client/settings/index.js.map +1 -1
  241. package/dist/collab/struct-routes.d.ts +1 -1
  242. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  243. package/dist/integrations/identity.d.ts.map +1 -1
  244. package/dist/integrations/identity.js +2 -11
  245. package/dist/integrations/identity.js.map +1 -1
  246. package/dist/jobs/actions/list-automation-runs.d.ts +8 -0
  247. package/dist/jobs/actions/list-automation-runs.d.ts.map +1 -0
  248. package/dist/jobs/actions/list-automation-runs.js +29 -0
  249. package/dist/jobs/actions/list-automation-runs.js.map +1 -0
  250. package/dist/jobs/actions/list-recurring-jobs.d.ts +2 -0
  251. package/dist/jobs/actions/list-recurring-jobs.d.ts.map +1 -1
  252. package/dist/jobs/actions/list-recurring-jobs.js +20 -7
  253. package/dist/jobs/actions/list-recurring-jobs.js.map +1 -1
  254. package/dist/jobs/actions/manage-recurring-job.d.ts +2 -0
  255. package/dist/jobs/actions/manage-recurring-job.js +30 -9
  256. package/dist/jobs/actions/manage-recurring-job.js.map +1 -1
  257. package/dist/jobs/background-automation-runner.d.ts.map +1 -1
  258. package/dist/jobs/background-automation-runner.js +60 -0
  259. package/dist/jobs/background-automation-runner.js.map +1 -1
  260. package/dist/jobs/cron.d.ts +16 -2
  261. package/dist/jobs/cron.d.ts.map +1 -1
  262. package/dist/jobs/cron.js +43 -6
  263. package/dist/jobs/cron.js.map +1 -1
  264. package/dist/jobs/frontmatter.d.ts +12 -0
  265. package/dist/jobs/frontmatter.d.ts.map +1 -1
  266. package/dist/jobs/frontmatter.js +8 -0
  267. package/dist/jobs/frontmatter.js.map +1 -1
  268. package/dist/jobs/run-history.d.ts +56 -0
  269. package/dist/jobs/run-history.d.ts.map +1 -0
  270. package/dist/jobs/run-history.js +169 -0
  271. package/dist/jobs/run-history.js.map +1 -0
  272. package/dist/jobs/scheduler.d.ts.map +1 -1
  273. package/dist/jobs/scheduler.js +51 -18
  274. package/dist/jobs/scheduler.js.map +1 -1
  275. package/dist/jobs/tools.d.ts.map +1 -1
  276. package/dist/jobs/tools.js +37 -6
  277. package/dist/jobs/tools.js.map +1 -1
  278. package/dist/localization/actions/get-localization-preference.d.ts +1 -2
  279. package/dist/localization/actions/get-localization-preference.d.ts.map +1 -1
  280. package/dist/localization/actions/get-localization-preference.js.map +1 -1
  281. package/dist/localization/actions/set-localization-preference.d.ts +3 -3
  282. package/dist/localization/actions/set-localization-preference.d.ts.map +1 -1
  283. package/dist/localization/actions/set-localization-preference.js +33 -8
  284. package/dist/localization/actions/set-localization-preference.js.map +1 -1
  285. package/dist/localization/default-messages.d.ts +25 -0
  286. package/dist/localization/default-messages.d.ts.map +1 -1
  287. package/dist/localization/default-messages.js +25 -0
  288. package/dist/localization/default-messages.js.map +1 -1
  289. package/dist/localization/shared.d.ts +16 -1
  290. package/dist/localization/shared.d.ts.map +1 -1
  291. package/dist/localization/shared.js +22 -4
  292. package/dist/localization/shared.js.map +1 -1
  293. package/dist/localization/user-timezone.d.ts +11 -0
  294. package/dist/localization/user-timezone.d.ts.map +1 -0
  295. package/dist/localization/user-timezone.js +31 -0
  296. package/dist/localization/user-timezone.js.map +1 -0
  297. package/dist/notifications/routes.d.ts +1 -1
  298. package/dist/observability/routes.d.ts +1 -1
  299. package/dist/org/app-roles-handlers.d.ts.map +1 -1
  300. package/dist/org/app-roles-handlers.js +1 -8
  301. package/dist/org/app-roles-handlers.js.map +1 -1
  302. package/dist/org/index.d.ts +1 -0
  303. package/dist/org/index.d.ts.map +1 -1
  304. package/dist/org/index.js +1 -0
  305. package/dist/org/index.js.map +1 -1
  306. package/dist/org/membership.d.ts +6 -0
  307. package/dist/org/membership.d.ts.map +1 -0
  308. package/dist/org/membership.js +16 -0
  309. package/dist/org/membership.js.map +1 -0
  310. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  311. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  312. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  313. package/dist/resources/handlers.d.ts +1 -1
  314. package/dist/review/actions/create-review-comment.d.ts +32 -1
  315. package/dist/review/actions/create-review-comment.d.ts.map +1 -1
  316. package/dist/review/actions/create-review-comment.js +3 -1
  317. package/dist/review/actions/create-review-comment.js.map +1 -1
  318. package/dist/review/actions/reply-review-comment.d.ts +32 -1
  319. package/dist/review/actions/reply-review-comment.d.ts.map +1 -1
  320. package/dist/review/actions/reply-review-comment.js +3 -1
  321. package/dist/review/actions/reply-review-comment.js.map +1 -1
  322. package/dist/review/index.d.ts +1 -0
  323. package/dist/review/index.d.ts.map +1 -1
  324. package/dist/review/index.js +1 -0
  325. package/dist/review/index.js.map +1 -1
  326. package/dist/review/notifications.d.ts +24 -0
  327. package/dist/review/notifications.d.ts.map +1 -0
  328. package/dist/review/notifications.js +123 -0
  329. package/dist/review/notifications.js.map +1 -0
  330. package/dist/review/types.d.ts +5 -0
  331. package/dist/review/types.d.ts.map +1 -1
  332. package/dist/review/types.js.map +1 -1
  333. package/dist/server/action-discovery.d.ts.map +1 -1
  334. package/dist/server/action-discovery.js +4 -0
  335. package/dist/server/action-discovery.js.map +1 -1
  336. package/dist/server/activity-notifications.d.ts +61 -0
  337. package/dist/server/activity-notifications.d.ts.map +1 -0
  338. package/dist/server/activity-notifications.js +103 -0
  339. package/dist/server/activity-notifications.js.map +1 -0
  340. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  341. package/dist/server/app-name.d.ts +18 -0
  342. package/dist/server/app-name.d.ts.map +1 -1
  343. package/dist/server/app-name.js +31 -0
  344. package/dist/server/app-name.js.map +1 -1
  345. package/dist/server/better-auth-instance.js +4 -4
  346. package/dist/server/better-auth-instance.js.map +1 -1
  347. package/dist/server/email-templates.d.ts +12 -0
  348. package/dist/server/email-templates.d.ts.map +1 -1
  349. package/dist/server/email-templates.js +57 -14
  350. package/dist/server/email-templates.js.map +1 -1
  351. package/dist/server/email.d.ts +12 -0
  352. package/dist/server/email.d.ts.map +1 -1
  353. package/dist/server/email.js +52 -5
  354. package/dist/server/email.js.map +1 -1
  355. package/dist/server/index.d.ts +1 -0
  356. package/dist/server/index.d.ts.map +1 -1
  357. package/dist/server/index.js +1 -0
  358. package/dist/server/index.js.map +1 -1
  359. package/dist/sharing/index.d.ts +1 -0
  360. package/dist/sharing/index.d.ts.map +1 -1
  361. package/dist/sharing/index.js +1 -0
  362. package/dist/sharing/index.js.map +1 -1
  363. package/dist/sharing/recipients.d.ts +31 -0
  364. package/dist/sharing/recipients.d.ts.map +1 -0
  365. package/dist/sharing/recipients.js +40 -0
  366. package/dist/sharing/recipients.js.map +1 -0
  367. package/dist/templates/chat/app/routes/settings.tsx +14 -22
  368. package/dist/templates/chat/vitest.config.ts +9 -0
  369. package/dist/triggers/actions/list-automations.d.ts +2 -0
  370. package/dist/triggers/actions/list-automations.d.ts.map +1 -1
  371. package/dist/triggers/actions/list-automations.js +19 -8
  372. package/dist/triggers/actions/list-automations.js.map +1 -1
  373. package/dist/triggers/actions/manage-automation.d.ts +6 -0
  374. package/dist/triggers/actions/manage-automation.js +12 -6
  375. package/dist/triggers/actions/manage-automation.js.map +1 -1
  376. package/dist/triggers/actions.d.ts.map +1 -1
  377. package/dist/triggers/actions.js +15 -4
  378. package/dist/triggers/actions.js.map +1 -1
  379. package/dist/triggers/dispatcher.d.ts.map +1 -1
  380. package/dist/triggers/dispatcher.js +19 -17
  381. package/dist/triggers/dispatcher.js.map +1 -1
  382. package/dist/triggers/routes.d.ts.map +1 -1
  383. package/dist/triggers/routes.js +6 -6
  384. package/dist/triggers/routes.js.map +1 -1
  385. package/dist/vitest-config.d.ts +10 -0
  386. package/dist/vitest-config.d.ts.map +1 -0
  387. package/dist/vitest-config.js +55 -0
  388. package/dist/vitest-config.js.map +1 -0
  389. package/package.json +5 -4
  390. package/src/automations/service.ts +41 -4
  391. package/src/cli/mcp-config-writers.ts +27 -21
  392. package/src/client/agent-page/AgentJobsTab.tsx +187 -85
  393. package/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
  394. package/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
  395. package/src/client/agent-page/TimezoneSelect.tsx +241 -0
  396. package/src/client/agent-page/use-jobs.ts +50 -10
  397. package/src/client/org/OrgSwitcher.tsx +26 -0
  398. package/src/client/settings/AccountSettingsCard.tsx +35 -31
  399. package/src/client/settings/SchedulingTimezoneField.tsx +71 -0
  400. package/src/client/settings/SettingsRow.tsx +113 -0
  401. package/src/client/settings/agent-settings-search.ts +2 -1
  402. package/src/client/settings/index.ts +6 -0
  403. package/src/integrations/identity.ts +2 -12
  404. package/src/jobs/actions/list-automation-runs.ts +33 -0
  405. package/src/jobs/actions/list-recurring-jobs.ts +27 -6
  406. package/src/jobs/actions/manage-recurring-job.ts +41 -9
  407. package/src/jobs/background-automation-runner.ts +88 -0
  408. package/src/jobs/cron.ts +52 -6
  409. package/src/jobs/frontmatter.ts +20 -0
  410. package/src/jobs/run-history.ts +242 -0
  411. package/src/jobs/scheduler.ts +73 -18
  412. package/src/jobs/tools.ts +54 -6
  413. package/src/localization/actions/get-localization-preference.ts +2 -2
  414. package/src/localization/actions/set-localization-preference.ts +49 -11
  415. package/src/localization/default-messages.ts +27 -0
  416. package/src/localization/shared.ts +39 -6
  417. package/src/localization/user-timezone.ts +36 -0
  418. package/src/org/app-roles-handlers.ts +1 -9
  419. package/src/org/index.ts +2 -0
  420. package/src/org/membership.ts +18 -0
  421. package/src/review/actions/create-review-comment.ts +4 -1
  422. package/src/review/actions/reply-review-comment.ts +4 -1
  423. package/src/review/index.ts +5 -0
  424. package/src/review/notifications.ts +163 -0
  425. package/src/review/types.ts +7 -0
  426. package/src/server/action-discovery.ts +4 -0
  427. package/src/server/activity-notifications.ts +174 -0
  428. package/src/server/app-name.ts +31 -0
  429. package/src/server/better-auth-instance.ts +4 -4
  430. package/src/server/email-templates.ts +70 -14
  431. package/src/server/email.ts +66 -3
  432. package/src/server/index.ts +10 -0
  433. package/src/sharing/index.ts +5 -0
  434. package/src/sharing/recipients.ts +67 -0
  435. package/src/templates/chat/app/routes/settings.tsx +14 -22
  436. package/src/templates/chat/vitest.config.ts +9 -0
  437. package/src/triggers/actions/list-automations.ts +27 -8
  438. package/src/triggers/actions/manage-automation.ts +17 -6
  439. package/src/triggers/actions.ts +16 -4
  440. package/src/triggers/dispatcher.ts +44 -30
  441. package/src/triggers/routes.ts +19 -6
  442. package/src/vitest-config.ts +68 -0
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { getDbExec, isPostgres } from "@agent-native/core/db";
15
- import { and, eq, inArray, sql } from "drizzle-orm";
15
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
16
16
 
17
17
  import { getDb, schema } from "../db/index.js";
18
18
 
@@ -37,6 +37,7 @@ export type UploadLeaseResult =
37
37
  | { held: true }
38
38
  | {
39
39
  held: false;
40
+ staleAttempt: boolean;
40
41
  status: string | null;
41
42
  failureReason: string | null;
42
43
  videoUrl: string | null;
@@ -53,7 +54,12 @@ export type UploadLeaseResult =
53
54
  */
54
55
  export async function renewUploadLease(
55
56
  recordingId: string,
56
- options: { now?: number; uploadProgress?: number } = {},
57
+ options: {
58
+ now?: number;
59
+ uploadProgress?: number;
60
+ attemptId?: string | null;
61
+ generationId?: string | null;
62
+ } = {},
57
63
  ): Promise<UploadLeaseResult> {
58
64
  const now = options.now ?? Date.now();
59
65
  const held = await getDb()
@@ -72,6 +78,23 @@ export async function renewUploadLease(
72
78
  and(
73
79
  eq(schema.recordings.id, recordingId),
74
80
  inArray(schema.recordings.status, [...IN_PROGRESS_STATUSES]),
81
+ ...(options.attemptId === undefined
82
+ ? []
83
+ : [
84
+ options.attemptId === null
85
+ ? isNull(schema.recordings.uploadAttemptId)
86
+ : eq(schema.recordings.uploadAttemptId, options.attemptId),
87
+ ]),
88
+ ...(options.generationId === undefined
89
+ ? []
90
+ : [
91
+ options.generationId === null
92
+ ? isNull(schema.recordings.uploadGenerationId)
93
+ : eq(
94
+ schema.recordings.uploadGenerationId,
95
+ options.generationId,
96
+ ),
97
+ ]),
75
98
  ),
76
99
  )
77
100
  .returning({ id: schema.recordings.id });
@@ -87,12 +110,23 @@ export async function renewUploadLease(
87
110
  videoUrl: schema.recordings.videoUrl,
88
111
  videoSizeBytes: schema.recordings.videoSizeBytes,
89
112
  durationMs: schema.recordings.durationMs,
113
+ uploadAttemptId: schema.recordings.uploadAttemptId,
114
+ uploadGenerationId: schema.recordings.uploadGenerationId,
90
115
  })
91
116
  .from(schema.recordings)
92
117
  .where(eq(schema.recordings.id, recordingId));
93
118
 
94
119
  return {
95
120
  held: false,
121
+ staleAttempt:
122
+ (options.attemptId !== undefined || options.generationId !== undefined) &&
123
+ IN_PROGRESS_STATUSES.includes(
124
+ row?.status as (typeof IN_PROGRESS_STATUSES)[number],
125
+ ) &&
126
+ ((options.attemptId !== undefined &&
127
+ row?.uploadAttemptId !== options.attemptId) ||
128
+ (options.generationId !== undefined &&
129
+ row?.uploadGenerationId !== options.generationId)),
96
130
  status: row?.status ?? null,
97
131
  failureReason: row?.failureReason ?? null,
98
132
  videoUrl: row?.videoUrl ?? null,
@@ -106,6 +140,7 @@ export interface ReapedUpload {
106
140
  ownerEmail: string;
107
141
  status: string;
108
142
  leaseExpiresAt: string;
143
+ uploadGenerationId: string | null;
109
144
  }
110
145
 
111
146
  export interface ReapResult {
@@ -137,7 +172,7 @@ export async function reapExpiredUploads(
137
172
 
138
173
  // guard:allow-unscoped — system upload reaper, owner-agnostic by design.
139
174
  const probe = await exec.execute({
140
- sql: `SELECT id, owner_email, status, upload_lease_expires_at
175
+ sql: `SELECT id, owner_email, status, upload_lease_expires_at, upload_generation_id
141
176
  FROM recordings
142
177
  WHERE status IN ('uploading', 'processing')
143
178
  AND upload_lease_expires_at IS NOT NULL
@@ -154,6 +189,10 @@ export async function reapExpiredUploads(
154
189
  ownerEmail: String(row.owner_email ?? ""),
155
190
  status: String(row.status ?? ""),
156
191
  leaseExpiresAt: String(row.upload_lease_expires_at ?? ""),
192
+ uploadGenerationId:
193
+ typeof row.upload_generation_id === "string"
194
+ ? row.upload_generation_id
195
+ : null,
157
196
  }));
158
197
 
159
198
  let failed = 0;
@@ -184,9 +223,16 @@ export async function reapExpiredUploads(
184
223
  failed = terminated.size;
185
224
 
186
225
  for (const id of terminated) {
226
+ const generationId = expired.find(
227
+ (row) => row.id === id,
228
+ )?.uploadGenerationId;
187
229
  await exec.execute({
188
230
  sql: `DELETE FROM application_state WHERE key = ${p(1)}`,
189
- args: [`resumable-session-${id}`],
231
+ args: [
232
+ generationId
233
+ ? `resumable-session-${id}-${generationId}`
234
+ : `resumable-session-${id}`,
235
+ ],
190
236
  });
191
237
  }
192
238
  }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Generate a filmstrip sprite — one JPEG holding a grid of evenly-spaced video
3
+ * frames — so the editor timeline renders thumbnails as a single cached image
4
+ * request instead of decoding the video in the browser.
5
+ *
6
+ * Why a sprite rather than per-frame files: one ffmpeg pass, one upload, one
7
+ * HTTP request, and the grid geometry is enough for CSS `background-position`
8
+ * to address any cell. The browser fallback in `app/lib/video-filmstrip.ts`
9
+ * needs one seek per frame and cannot read cross-origin media at all.
10
+ *
11
+ * Three constraints worth keeping in mind:
12
+ *
13
+ * - Cells are padded to an exact size (`force_original_aspect_ratio=decrease`
14
+ * plus `pad`) so the returned grid geometry is exact without probing the
15
+ * source. Letterboxed bars on odd aspect ratios are the deliberate cost of
16
+ * not having to decode the output to learn its dimensions.
17
+ * - Sampling starts half a cell in, matching the browser fallback: cell `i`
18
+ * shows the midpoint of the time slot it occupies, not its leading edge.
19
+ * - Input arrives as bytes and is written to a temp file, like every other
20
+ * ffmpeg path here. That means the whole file is buffered in memory by the
21
+ * caller, which is the practical ceiling on this approach for long
22
+ * recordings on small hosts.
23
+ */
24
+
25
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ import { isFfmpegAvailable, runFfmpeg, withRemuxSlot } from "./video-remux.js";
30
+
31
+ const SPRITE_TIMEOUT_MS = 90_000;
32
+
33
+ export const DEFAULT_FILMSTRIP_FRAME_COUNT = 40;
34
+ // A sprite is decoded whole by the browser, so cap the grid: 120 cells at the
35
+ // default size is already a ~1600x1080 image.
36
+ export const MAX_FILMSTRIP_FRAME_COUNT = 120;
37
+ export const DEFAULT_FILMSTRIP_FRAME_WIDTH = 160;
38
+ export const DEFAULT_FILMSTRIP_FRAME_HEIGHT = 90;
39
+ export const DEFAULT_FILMSTRIP_COLUMNS = 10;
40
+
41
+ export type FilmstripSpriteStatus =
42
+ | "generated"
43
+ | "skipped-no-ffmpeg"
44
+ | "skipped-no-duration"
45
+ | "skipped-no-media"
46
+ | "failed-ffmpeg"
47
+ | "failed-empty-output";
48
+
49
+ /** Everything the UI needs to address one cell of the sprite. */
50
+ export interface FilmstripSpriteGrid {
51
+ frameCount: number;
52
+ columns: number;
53
+ rows: number;
54
+ frameWidth: number;
55
+ frameHeight: number;
56
+ }
57
+
58
+ export interface FilmstripSpriteResult {
59
+ status: FilmstripSpriteStatus;
60
+ /** Present only when `status === "generated"`. */
61
+ sprite?: { bytes: Uint8Array; grid: FilmstripSpriteGrid };
62
+ detail?: string;
63
+ }
64
+
65
+ export interface GenerateFilmstripSpriteInput {
66
+ mediaBytes: Uint8Array;
67
+ durationMs: number;
68
+ frameCount?: number;
69
+ frameWidth?: number;
70
+ frameHeight?: number;
71
+ columns?: number;
72
+ }
73
+
74
+ /**
75
+ * Lay `frameCount` cells out into a grid no wider than `columns`. Exported so
76
+ * callers can compute the same geometry the sprite will have without
77
+ * regenerating it.
78
+ */
79
+ export function filmstripGrid(input: {
80
+ frameCount: number;
81
+ columns: number;
82
+ frameWidth: number;
83
+ frameHeight: number;
84
+ }): FilmstripSpriteGrid {
85
+ const frameCount = Math.min(
86
+ MAX_FILMSTRIP_FRAME_COUNT,
87
+ Math.max(1, Math.floor(input.frameCount)),
88
+ );
89
+ const columns = Math.max(1, Math.min(input.columns, frameCount));
90
+ return {
91
+ frameCount,
92
+ columns,
93
+ rows: Math.ceil(frameCount / columns),
94
+ frameWidth: input.frameWidth,
95
+ frameHeight: input.frameHeight,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build the ffmpeg filter chain that samples `frameCount` frames across
101
+ * `durationMs` and tiles them into `grid`. Exported for tests: the sampling
102
+ * offset and rate are the parts most likely to drift from the browser
103
+ * fallback's expectations.
104
+ */
105
+ export function filmstripSpriteFilter(input: {
106
+ durationMs: number;
107
+ grid: FilmstripSpriteGrid;
108
+ }): { seekSeconds: string; filter: string } {
109
+ const { durationMs, grid } = input;
110
+ const cellMs = durationMs / grid.frameCount;
111
+ const fps = grid.frameCount / (durationMs / 1000);
112
+
113
+ return {
114
+ // Half a cell in, so each tile is the midpoint of its slot.
115
+ seekSeconds: (cellMs / 2 / 1000).toFixed(6),
116
+ filter: [
117
+ `fps=${fps.toFixed(6)}`,
118
+ `scale=${grid.frameWidth}:${grid.frameHeight}:force_original_aspect_ratio=decrease`,
119
+ `pad=${grid.frameWidth}:${grid.frameHeight}:-1:-1:color=black`,
120
+ `tile=${grid.columns}x${grid.rows}`,
121
+ ].join(","),
122
+ };
123
+ }
124
+
125
+ export async function generateFilmstripSprite(
126
+ input: GenerateFilmstripSpriteInput,
127
+ ): Promise<FilmstripSpriteResult> {
128
+ const {
129
+ mediaBytes,
130
+ durationMs,
131
+ frameCount = DEFAULT_FILMSTRIP_FRAME_COUNT,
132
+ frameWidth = DEFAULT_FILMSTRIP_FRAME_WIDTH,
133
+ frameHeight = DEFAULT_FILMSTRIP_FRAME_HEIGHT,
134
+ columns = DEFAULT_FILMSTRIP_COLUMNS,
135
+ } = input;
136
+
137
+ if (mediaBytes.byteLength === 0) {
138
+ return { status: "skipped-no-media" };
139
+ }
140
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
141
+ return { status: "skipped-no-duration" };
142
+ }
143
+ if (!isFfmpegAvailable()) {
144
+ return {
145
+ status: "skipped-no-ffmpeg",
146
+ detail:
147
+ "No ffmpeg binary available; the editor falls back to browser-side frame extraction.",
148
+ };
149
+ }
150
+
151
+ const grid = filmstripGrid({ frameCount, columns, frameWidth, frameHeight });
152
+ const { seekSeconds, filter } = filmstripSpriteFilter({ durationMs, grid });
153
+
154
+ const dir = await mkdtemp(join(tmpdir(), "clips-filmstrip-"));
155
+ const inputPath = join(dir, "input.media");
156
+ const outputPath = join(dir, "sprite.jpg");
157
+
158
+ try {
159
+ await writeFile(inputPath, mediaBytes);
160
+ await withRemuxSlot(() =>
161
+ runFfmpeg(
162
+ [
163
+ "-hide_banner",
164
+ "-loglevel",
165
+ "error",
166
+ "-nostdin",
167
+ "-y",
168
+ // Input seeking: cheap, and puts the first sample at a cell midpoint.
169
+ "-ss",
170
+ seekSeconds,
171
+ "-i",
172
+ inputPath,
173
+ "-an",
174
+ "-sn",
175
+ "-vf",
176
+ filter,
177
+ // The tile filter emits the finished grid as a single frame.
178
+ "-frames:v",
179
+ "1",
180
+ "-q:v",
181
+ "4",
182
+ "-f",
183
+ "image2",
184
+ outputPath,
185
+ ],
186
+ { timeoutMs: SPRITE_TIMEOUT_MS, label: "filmstrip sprite" },
187
+ ),
188
+ );
189
+
190
+ const bytes = new Uint8Array(await readFile(outputPath));
191
+ if (bytes.byteLength === 0) {
192
+ return {
193
+ status: "failed-empty-output",
194
+ detail: "ffmpeg reported success but wrote an empty sprite",
195
+ };
196
+ }
197
+
198
+ return { status: "generated", sprite: { bytes, grid } };
199
+ } catch (err) {
200
+ return {
201
+ status: "failed-ffmpeg",
202
+ detail: err instanceof Error ? err.message : String(err),
203
+ };
204
+ } finally {
205
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
206
+ }
207
+ }
@@ -125,7 +125,11 @@ function startsWithMagic(bytes: Uint8Array, magic: number[]): boolean {
125
125
  return true;
126
126
  }
127
127
 
128
- async function runFfmpeg(args: string[]): Promise<void> {
128
+ export async function runFfmpeg(
129
+ args: string[],
130
+ options: { timeoutMs?: number; label?: string } = {},
131
+ ): Promise<void> {
132
+ const { timeoutMs = REMUX_TIMEOUT_MS, label = "remux" } = options;
129
133
  await new Promise<void>((resolve, reject) => {
130
134
  const child = spawn(ffmpegCommand(), args, {
131
135
  stdio: ["ignore", "ignore", "pipe"],
@@ -133,8 +137,8 @@ async function runFfmpeg(args: string[]): Promise<void> {
133
137
  let stderr = "";
134
138
  const timeout = setTimeout(() => {
135
139
  child.kill("SIGKILL");
136
- reject(new Error(`ffmpeg remux timed out\n${stderr}`));
137
- }, REMUX_TIMEOUT_MS);
140
+ reject(new Error(`ffmpeg ${label} timed out\n${stderr}`));
141
+ }, timeoutMs);
138
142
 
139
143
  child.stderr?.on("data", (chunk: Buffer) => {
140
144
  stderr = (stderr + chunk.toString("utf8")).slice(-STDERR_LIMIT);
@@ -244,7 +248,12 @@ export async function probeHasAudioStream(
244
248
  }
245
249
  }
246
250
 
247
- async function withRemuxSlot<T>(fn: () => Promise<T>): Promise<T> {
251
+ /**
252
+ * Bound concurrent ffmpeg invocations process-wide. Every ffmpeg caller must go
253
+ * through this, including non-remux jobs — the limit protects the box, not the
254
+ * remux path specifically.
255
+ */
256
+ export async function withRemuxSlot<T>(fn: () => Promise<T>): Promise<T> {
248
257
  if (activeRemuxes >= MAX_CONCURRENT_REMUXES) {
249
258
  await new Promise<void>((resolve) => remuxWaiters.push(resolve));
250
259
  }
@@ -193,6 +193,12 @@ const migrations = runMigrations(
193
193
  description TEXT NOT NULL DEFAULT '',
194
194
  thumbnail_url TEXT,
195
195
  animated_thumbnail_url TEXT,
196
+ filmstrip_url TEXT,
197
+ filmstrip_frame_count INTEGER NOT NULL DEFAULT 0,
198
+ filmstrip_columns INTEGER NOT NULL DEFAULT 0,
199
+ filmstrip_rows INTEGER NOT NULL DEFAULT 0,
200
+ filmstrip_frame_width INTEGER NOT NULL DEFAULT 0,
201
+ filmstrip_frame_height INTEGER NOT NULL DEFAULT 0,
196
202
  duration_ms INTEGER NOT NULL DEFAULT 0,
197
203
  video_url TEXT,
198
204
  video_format TEXT NOT NULL DEFAULT 'webm',
@@ -899,6 +905,16 @@ const migrations = runMigrations(
899
905
  `UPDATE recordings SET upload_lease_expires_at = '${uploadLeaseExpiry()}' WHERE upload_lease_expires_at IS NULL AND status IN ('uploading', 'processing')`,
900
906
  ].join("; "),
901
907
  },
908
+ {
909
+ version: 54,
910
+ name: "recording-upload-attempt-fence",
911
+ sql: `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS upload_attempt_id TEXT`,
912
+ },
913
+ {
914
+ version: 55,
915
+ name: "recording-upload-generation-fence",
916
+ sql: `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS upload_generation_id TEXT`,
917
+ },
902
918
  ],
903
919
  { table: "clips_migrations" },
904
920
  );
@@ -66,6 +66,7 @@ export default defineEventHandler(async (event: H3Event) => {
66
66
  ownerEmail: schema.recordings.ownerEmail,
67
67
  orgId: schema.recordings.orgId,
68
68
  status: schema.recordings.status,
69
+ uploadGenerationId: schema.recordings.uploadGenerationId,
69
70
  })
70
71
  .from(schema.recordings)
71
72
  .where(eq(schema.recordings.id, recordingId))
@@ -120,6 +121,9 @@ export default defineEventHandler(async (event: H3Event) => {
120
121
  const result = await finalizeRecording.run({
121
122
  id: recordingId,
122
123
  mediaVerificationRetryAttempt: retryAttempt ?? 1,
124
+ ...(recording.uploadGenerationId
125
+ ? { uploadGenerationId: recording.uploadGenerationId }
126
+ : {}),
123
127
  });
124
128
  return { ok: true, kind, result };
125
129
  }
@@ -6,14 +6,13 @@
6
6
  */
7
7
 
8
8
  import {
9
- deleteAppState,
9
+ compareAndSetManyAppState,
10
10
  readAppState,
11
11
  writeAppState,
12
- deleteAppStateByPrefix,
13
12
  } from "@agent-native/core/application-state";
14
13
  import { runWithRequestContext } from "@agent-native/core/server";
15
14
  import { isStoredButUnservableFinalizeError } from "@shared/finalize-recovery.js";
16
- import { and, eq } from "drizzle-orm";
15
+ import { and, eq, isNull } from "drizzle-orm";
17
16
  import {
18
17
  defineEventHandler,
19
18
  getRouterParam,
@@ -24,6 +23,7 @@ import {
24
23
 
25
24
  import { getDb, schema } from "../../../../db/index.js";
26
25
  import { mediaVerificationStateKey } from "../../../../lib/media-verification-state.js";
26
+ import { deleteRecordingChunks } from "../../../../lib/recording-upload-state.js";
27
27
  import {
28
28
  getEventOwnerContext,
29
29
  ownerEmailMatches,
@@ -44,11 +44,25 @@ export default defineEventHandler(async (event: H3Event) => {
44
44
  const { userEmail: ownerEmail, orgId } = await getEventOwnerContext(event);
45
45
  const body = (await readBody(event).catch(() => null)) as {
46
46
  reason?: unknown;
47
+ attemptId?: unknown;
48
+ uploadGenerationId?: unknown;
47
49
  } | null;
48
50
  const failureReason =
49
51
  typeof body?.reason === "string" && body.reason.trim()
50
52
  ? body.reason.trim().slice(0, 1000)
51
53
  : "Upload aborted by user";
54
+ const requestedAttemptId =
55
+ typeof body?.attemptId === "string" &&
56
+ body.attemptId.length > 0 &&
57
+ body.attemptId.length <= 128
58
+ ? body.attemptId
59
+ : null;
60
+ const requestedGenerationId =
61
+ typeof body?.uploadGenerationId === "string" &&
62
+ body.uploadGenerationId.length > 0 &&
63
+ body.uploadGenerationId.length <= 128
64
+ ? body.uploadGenerationId
65
+ : null;
52
66
 
53
67
  return runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
54
68
  const db = getDb();
@@ -59,6 +73,8 @@ export default defineEventHandler(async (event: H3Event) => {
59
73
  status: schema.recordings.status,
60
74
  videoUrl: schema.recordings.videoUrl,
61
75
  failureReason: schema.recordings.failureReason,
76
+ uploadAttemptId: schema.recordings.uploadAttemptId,
77
+ uploadGenerationId: schema.recordings.uploadGenerationId,
62
78
  })
63
79
  .from(schema.recordings)
64
80
  .where(
@@ -73,17 +89,43 @@ export default defineEventHandler(async (event: H3Event) => {
73
89
  return { error: "Recording not found" };
74
90
  }
75
91
 
92
+ const existingAttemptId = existing.uploadAttemptId ?? null;
93
+ const existingGenerationId = existing.uploadGenerationId ?? null;
94
+ if (
95
+ requestedAttemptId !== existingAttemptId ||
96
+ requestedGenerationId !== existingGenerationId
97
+ ) {
98
+ setResponseStatus(event, 409);
99
+ return {
100
+ error: "A newer upload retry is already active.",
101
+ staleAttempt: true,
102
+ };
103
+ }
104
+
76
105
  if (existing.status === "ready" && existing.videoUrl) {
77
106
  return { ok: true, recordingId, alreadyReady: true, chunksCleared: 0 };
78
107
  }
79
108
 
80
- const existingUploadStateRaw = await readAppState(
81
- `recording-upload-${recordingId}`,
82
- ).catch(() => null);
109
+ const uploadStateKey = `recording-upload-${recordingId}`;
110
+ const verificationStateKey = mediaVerificationStateKey(recordingId);
111
+ const [existingUploadStateRaw, existingVerificationStateRaw] =
112
+ await Promise.all([
113
+ readAppState(uploadStateKey),
114
+ readAppState(verificationStateKey),
115
+ ]);
83
116
  const existingUploadState =
84
117
  existingUploadStateRaw && typeof existingUploadStateRaw === "object"
85
118
  ? (existingUploadStateRaw as Record<string, unknown>)
86
119
  : {};
120
+ const existingUploadStateSnapshot =
121
+ existingUploadStateRaw && typeof existingUploadStateRaw === "object"
122
+ ? (existingUploadStateRaw as Record<string, unknown>)
123
+ : null;
124
+ const existingVerificationStateSnapshot =
125
+ existingVerificationStateRaw &&
126
+ typeof existingVerificationStateRaw === "object"
127
+ ? (existingVerificationStateRaw as Record<string, unknown>)
128
+ : null;
87
129
  if (
88
130
  existing.status === "processing" &&
89
131
  existingUploadState.pendingMediaVerification === true
@@ -99,46 +141,87 @@ export default defineEventHandler(async (event: H3Event) => {
99
141
  const preserveRecoveryState =
100
142
  isStoredButUnservableFinalizeError(failureReason) ||
101
143
  isStoredButUnservableFinalizeError(existing.failureReason);
102
- const resumableSession = preserveRecoveryState
144
+ let resumableSession = preserveRecoveryState
103
145
  ? null
104
- : await getResumableSession(recordingId).catch(() => null);
105
-
106
- // Already a terminal failure (e.g. a duplicate/retried abort call, or
107
- // finalize's own failChunkAssembly already flipped it) — no-op unless a
108
- // prior provider cleanup failure intentionally retained a resumable
109
- // session for this retry.
110
- if (
111
- existing.status === "failed" &&
112
- !preserveRecoveryState &&
113
- !resumableSession
114
- ) {
115
- return { ok: true, recordingId, alreadyFailed: true, chunksCleared: 0 };
116
- }
146
+ : await getResumableSession(recordingId, existingGenerationId);
117
147
 
118
148
  const now = new Date().toISOString();
119
- await db
149
+ const aborted = await db
120
150
  .update(schema.recordings)
121
151
  .set({
122
152
  status: "failed",
123
153
  failureReason,
124
154
  updatedAt: now,
125
155
  })
126
- .where(eq(schema.recordings.id, recordingId));
127
-
128
- await writeAppState(`recording-upload-${recordingId}`, {
129
- ...existingUploadState,
130
- recordingId,
131
- status: "failed",
132
- failureReason,
133
- updatedAt: now,
134
- });
135
- await deleteAppState(mediaVerificationStateKey(recordingId)).catch(
136
- () => {},
137
- );
156
+ .where(
157
+ and(
158
+ eq(schema.recordings.id, recordingId),
159
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
160
+ eq(schema.recordings.status, existing.status),
161
+ existingAttemptId === null
162
+ ? isNull(schema.recordings.uploadAttemptId)
163
+ : eq(schema.recordings.uploadAttemptId, existingAttemptId),
164
+ existingGenerationId === null
165
+ ? isNull(schema.recordings.uploadGenerationId)
166
+ : eq(schema.recordings.uploadGenerationId, existingGenerationId),
167
+ ),
168
+ )
169
+ .returning({ id: schema.recordings.id });
170
+
171
+ if (aborted.length !== 1) {
172
+ setResponseStatus(event, 409);
173
+ return {
174
+ error: "A newer upload retry is already active.",
175
+ staleAttempt: true,
176
+ };
177
+ }
178
+
179
+ const auxiliaryStateUpdated = await compareAndSetManyAppState([
180
+ {
181
+ key: uploadStateKey,
182
+ expectedValue: existingUploadStateSnapshot,
183
+ nextValue: {
184
+ ...existingUploadState,
185
+ recordingId,
186
+ status: "failed",
187
+ failureReason,
188
+ updatedAt: now,
189
+ },
190
+ },
191
+ ...(existingVerificationStateSnapshot
192
+ ? [
193
+ {
194
+ key: verificationStateKey,
195
+ expectedValue: existingVerificationStateSnapshot,
196
+ nextValue: null,
197
+ },
198
+ ]
199
+ : []),
200
+ ]);
201
+ if (!auxiliaryStateUpdated) {
202
+ console.info(
203
+ `[abort] upload state changed after abort claim; preserving replacement state for ${recordingId}`,
204
+ );
205
+ }
206
+
207
+ // Reset may have started this exact generation's provider session after
208
+ // our preflight read but before the abort claim. Re-read after the row CAS
209
+ // so either abort observes the late handle or reset observes the lost row
210
+ // claim and compensates it.
211
+ if (!preserveRecoveryState) {
212
+ resumableSession = await getResumableSession(
213
+ recordingId,
214
+ existingGenerationId,
215
+ );
216
+ }
138
217
 
139
218
  const cleared = preserveRecoveryState
140
219
  ? 0
141
- : await deleteAppStateByPrefix(`recording-chunks-${recordingId}-`);
220
+ : await deleteRecordingChunks(
221
+ ownerEmail,
222
+ recordingId,
223
+ existingGenerationId,
224
+ );
142
225
  if (!preserveRecoveryState) {
143
226
  if (resumableSession) {
144
227
  const provider = await resolveResumableUploadProvider(
@@ -166,10 +249,14 @@ export default defineEventHandler(async (event: H3Event) => {
166
249
  // retry can still address the multipart upload. Deleting it here would
167
250
  // permanently orphan the provider-side session.
168
251
  if (providerCleanupSucceeded) {
169
- await deleteResumableSession(recordingId).catch(() => {});
252
+ await deleteResumableSession(recordingId, existingGenerationId).catch(
253
+ () => {},
254
+ );
170
255
  }
171
256
  } else {
172
- await deleteResumableSession(recordingId).catch(() => {});
257
+ await deleteResumableSession(recordingId, existingGenerationId).catch(
258
+ () => {},
259
+ );
173
260
  }
174
261
  }
175
262
  await writeAppState("refresh-signal", { ts: Date.now() });