@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.
- package/corpus/README.md +3 -3
- package/corpus/core/CHANGELOG.md +107 -0
- package/corpus/core/package.json +3 -2
- package/corpus/core/src/automations/service.ts +41 -4
- package/corpus/core/src/cli/mcp-config-writers.ts +27 -21
- package/corpus/core/src/client/agent-page/AgentJobsTab.tsx +187 -85
- package/corpus/core/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
- package/corpus/core/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
- package/corpus/core/src/client/agent-page/TimezoneSelect.tsx +241 -0
- package/corpus/core/src/client/agent-page/use-jobs.ts +50 -10
- package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -0
- package/corpus/core/src/client/settings/AccountSettingsCard.tsx +35 -31
- package/corpus/core/src/client/settings/SchedulingTimezoneField.tsx +71 -0
- package/corpus/core/src/client/settings/SettingsRow.tsx +113 -0
- package/corpus/core/src/client/settings/agent-settings-search.ts +2 -1
- package/corpus/core/src/client/settings/index.ts +6 -0
- package/corpus/core/src/integrations/identity.ts +2 -12
- package/corpus/core/src/jobs/actions/list-automation-runs.ts +33 -0
- package/corpus/core/src/jobs/actions/list-recurring-jobs.ts +27 -6
- package/corpus/core/src/jobs/actions/manage-recurring-job.ts +41 -9
- package/corpus/core/src/jobs/background-automation-runner.ts +88 -0
- package/corpus/core/src/jobs/cron.ts +52 -6
- package/corpus/core/src/jobs/frontmatter.ts +20 -0
- package/corpus/core/src/jobs/run-history.ts +242 -0
- package/corpus/core/src/jobs/scheduler.ts +73 -18
- package/corpus/core/src/jobs/tools.ts +54 -6
- package/corpus/core/src/localization/actions/get-localization-preference.ts +2 -2
- package/corpus/core/src/localization/actions/set-localization-preference.ts +49 -11
- package/corpus/core/src/localization/default-messages.ts +27 -0
- package/corpus/core/src/localization/shared.ts +39 -6
- package/corpus/core/src/localization/user-timezone.ts +36 -0
- package/corpus/core/src/org/app-roles-handlers.ts +1 -9
- package/corpus/core/src/org/index.ts +2 -0
- package/corpus/core/src/org/membership.ts +18 -0
- package/corpus/core/src/review/actions/create-review-comment.ts +4 -1
- package/corpus/core/src/review/actions/reply-review-comment.ts +4 -1
- package/corpus/core/src/review/index.ts +5 -0
- package/corpus/core/src/review/notifications.ts +163 -0
- package/corpus/core/src/review/types.ts +7 -0
- package/corpus/core/src/server/action-discovery.ts +4 -0
- package/corpus/core/src/server/activity-notifications.ts +174 -0
- package/corpus/core/src/server/app-name.ts +31 -0
- package/corpus/core/src/server/better-auth-instance.ts +4 -4
- package/corpus/core/src/server/email-templates.ts +70 -14
- package/corpus/core/src/server/email.ts +66 -3
- package/corpus/core/src/server/index.ts +10 -0
- package/corpus/core/src/sharing/index.ts +5 -0
- package/corpus/core/src/sharing/recipients.ts +67 -0
- package/corpus/core/src/triggers/actions/list-automations.ts +27 -8
- package/corpus/core/src/triggers/actions/manage-automation.ts +17 -6
- package/corpus/core/src/triggers/actions.ts +16 -4
- package/corpus/core/src/triggers/dispatcher.ts +44 -30
- package/corpus/core/src/triggers/routes.ts +19 -6
- package/corpus/core/src/vitest-config.ts +68 -0
- package/corpus/core/vitest.config.ts +5 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +37 -53
- package/corpus/templates/analytics/changelog/2026-08-01-new-error-alerts-now-reach-you-by-email-and-a-scheduled-dash.md +6 -0
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +43 -0
- package/corpus/templates/analytics/server/lib/error-capture.ts +5 -1
- package/corpus/templates/analytics/vitest.config.ts +16 -12
- package/corpus/templates/assets/actions/refresh-generation-run.ts +3 -0
- package/corpus/templates/assets/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/assets/app/hooks/use-assets-prefs.ts +55 -0
- package/corpus/templates/assets/app/i18n-data.ts +30 -0
- package/corpus/templates/assets/app/routes/settings.tsx +58 -55
- package/corpus/templates/assets/changelog/2026-08-01-get-an-email-when-a-generation-you-started-finishes-or-fails.md +6 -0
- package/corpus/templates/assets/server/lib/generation-run-notifications.ts +89 -0
- package/corpus/templates/assets/server/lib/video-runs.ts +8 -1
- package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.get.ts +17 -0
- package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.put.ts +36 -0
- package/corpus/templates/assets/shared/assets-user-prefs.ts +10 -0
- package/corpus/templates/assets/vitest.config.ts +16 -12
- package/corpus/templates/brain/app/routes/settings.tsx +15 -15
- package/corpus/templates/brain/vitest.config.ts +16 -12
- package/corpus/templates/calendar/app/pages/Settings.tsx +32 -40
- package/corpus/templates/calendar/changelog/2026-08-01-language-and-appearance-now-sit-together-in-one-preferences-.md +6 -0
- package/corpus/templates/calendar/vitest.config.ts +16 -12
- package/corpus/templates/chat/app/routes/settings.tsx +14 -22
- package/corpus/templates/chat/vitest.config.ts +9 -0
- package/corpus/templates/clips/.agents/skills/video-editing/SKILL.md +31 -0
- package/corpus/templates/clips/AGENTS.md +1 -0
- package/corpus/templates/clips/actions/add-comment.ts +12 -1
- package/corpus/templates/clips/actions/create-meeting.ts +2 -2
- package/corpus/templates/clips/actions/create-recording.ts +2 -2
- package/corpus/templates/clips/actions/finalize-recording.ts +42 -6
- package/corpus/templates/clips/actions/generate-filmstrip.ts +154 -0
- package/corpus/templates/clips/actions/get-recording-player-data.ts +6 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +2 -2
- package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +258 -0
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +11 -3
- package/corpus/templates/clips/actions/react-to-comment.ts +13 -0
- package/corpus/templates/clips/actions/react-to-recording.ts +10 -1
- package/corpus/templates/clips/actions/reply-to-comment.ts +12 -1
- package/corpus/templates/clips/actions/stitch-recordings.ts +2 -2
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +152 -23
- package/corpus/templates/clips/app/components/editor/waveform.tsx +237 -75
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +23 -1
- package/corpus/templates/clips/app/components/settings/ai-setup-section.tsx +441 -0
- package/corpus/templates/clips/app/components/settings/slack-section.tsx +329 -0
- package/corpus/templates/clips/app/components/settings/types.ts +14 -0
- package/corpus/templates/clips/app/components/settings/video-storage-section.tsx +443 -0
- package/corpus/templates/clips/app/hooks/use-secret-status.ts +69 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +9 -1
- package/corpus/templates/clips/app/lib/video-filmstrip.ts +262 -0
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +165 -1351
- package/corpus/templates/clips/changelog/2026-07-27-saved-clips-now-resume-from-the-last-uploaded-byte-after-an-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-editor-timeline-now-shows-video-frames-behind-the-waveform-g.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-email-notifications-now-go-out-when-someone-comments-on-or-r.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-you-can-now-set-a-default-visibility-for-new-recordings-in-s.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-01-email-notifications-for-comments-and-reactions-now-actually-.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-the-desktop-app-now-opens-directly-beneath-its-tray-icon-on-.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +1 -0
- package/corpus/templates/clips/chrome-extension/vitest.config.ts +9 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/src/app.tsx +17 -1
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +331 -36
- package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +129 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +815 -15
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +61 -11
- package/corpus/templates/clips/desktop/vitest.config.ts +9 -0
- package/corpus/templates/clips/server/db/schema.ts +17 -0
- package/corpus/templates/clips/server/jobs/media-verification.ts +4 -0
- package/corpus/templates/clips/server/lib/activity-notifications.ts +185 -0
- package/corpus/templates/clips/server/lib/recording-media-cleanup.ts +2 -0
- package/corpus/templates/clips/server/lib/recording-upload-state.ts +46 -9
- package/corpus/templates/clips/server/lib/recordings.ts +24 -0
- package/corpus/templates/clips/server/lib/resumable-session.ts +10 -4
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +109 -0
- package/corpus/templates/clips/server/lib/upload-interruption.ts +8 -0
- package/corpus/templates/clips/server/lib/upload-lease.ts +50 -4
- package/corpus/templates/clips/server/lib/video-filmstrip-sprite.ts +207 -0
- package/corpus/templates/clips/server/lib/video-remux.ts +13 -4
- package/corpus/templates/clips/server/plugins/db.ts +16 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +4 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +123 -36
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +159 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +197 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +193 -39
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +191 -12
- package/corpus/templates/clips/shared/clips-ai-prefs.ts +6 -0
- package/corpus/templates/clips/shared/feature-flags.ts +12 -0
- package/corpus/templates/clips/shared/recording-core.ts +4 -0
- package/corpus/templates/clips/vitest.config.ts +23 -19
- package/corpus/templates/content/actions/add-comment.ts +15 -1
- package/corpus/templates/content/actions/list-documents.ts +1 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +5 -2
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +50 -1
- package/corpus/templates/content/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/content/app/hooks/use-content-prefs.ts +55 -0
- package/corpus/templates/content/app/i18n-data.ts +40 -0
- package/corpus/templates/content/app/routes/_app.settings.tsx +47 -18
- package/corpus/templates/content/changelog/2026-07-30-breadcrumb-menus-now-hide-internal-system-pages.md +6 -0
- package/corpus/templates/content/changelog/2026-08-01-get-an-email-when-someone-comments-on-replies-in-or-mentions.md +6 -0
- package/corpus/templates/content/server/lib/comment-notifications.ts +146 -0
- package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.get.ts +17 -0
- package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.put.ts +36 -0
- package/corpus/templates/content/shared/content-user-prefs.ts +11 -0
- package/corpus/templates/content/vitest.config.ts +20 -16
- package/corpus/templates/crm/app/routes/settings.tsx +14 -22
- package/corpus/templates/crm/vitest.config.ts +8 -4
- package/corpus/templates/design/app/routes/settings.tsx +14 -22
- package/corpus/templates/design/changelog/2026-08-01-review-comments-replies-and-mentions-now-send-an-email-to-th.md +6 -0
- package/corpus/templates/design/vitest.config.ts +18 -12
- package/corpus/templates/dispatch/app/routes/settings.tsx +26 -40
- package/corpus/templates/dispatch/vitest.config.ts +11 -7
- package/corpus/templates/factory/app/routes/settings.tsx +14 -22
- package/corpus/templates/factory/vitest.config.ts +9 -0
- package/corpus/templates/forms/app/routes/_app.settings.tsx +14 -22
- package/corpus/templates/forms/vitest.config.ts +27 -23
- package/corpus/templates/macros/app/routes/settings.tsx +14 -22
- package/corpus/templates/macros/vitest.config.ts +16 -12
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +14 -16
- package/corpus/templates/mail/vitest.config.ts +16 -12
- package/corpus/templates/plan/app/routes/settings.tsx +30 -44
- package/corpus/templates/plan/vitest.config.ts +24 -20
- package/corpus/templates/slides/actions/add-slide-comment.ts +12 -1
- package/corpus/templates/slides/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/slides/app/hooks/use-slides-prefs.ts +55 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
- package/corpus/templates/slides/app/routes/settings.tsx +45 -22
- package/corpus/templates/slides/changelog/2026-08-01-get-an-email-when-someone-comments-on-or-replies-in-your-dec.md +6 -0
- package/corpus/templates/slides/server/lib/comment-notifications.ts +182 -0
- package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.get.ts +16 -0
- package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.put.ts +36 -0
- package/corpus/templates/slides/shared/slides-user-prefs.ts +11 -0
- package/corpus/templates/slides/vitest.config.ts +17 -13
- package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +2 -0
- package/corpus/templates/tasks/app/routes/settings.tsx +14 -22
- package/corpus/templates/tasks/vitest.config.ts +17 -13
- package/corpus/toolkit/CHANGELOG.md +9 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/vitest.config.ts +3 -0
- package/dist/automations/service.d.ts +2 -0
- package/dist/automations/service.d.ts.map +1 -1
- package/dist/automations/service.js +29 -4
- package/dist/automations/service.js.map +1 -1
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +24 -23
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/client/agent-page/AgentJobsTab.d.ts.map +1 -1
- package/dist/client/agent-page/AgentJobsTab.js +86 -29
- package/dist/client/agent-page/AgentJobsTab.js.map +1 -1
- package/dist/client/agent-page/AutomationDetailsDialog.d.ts +21 -0
- package/dist/client/agent-page/AutomationDetailsDialog.d.ts.map +1 -0
- package/dist/client/agent-page/AutomationDetailsDialog.js +33 -0
- package/dist/client/agent-page/AutomationDetailsDialog.js.map +1 -0
- package/dist/client/agent-page/AutomationScheduleDialog.d.ts +15 -0
- package/dist/client/agent-page/AutomationScheduleDialog.d.ts.map +1 -0
- package/dist/client/agent-page/AutomationScheduleDialog.js +46 -0
- package/dist/client/agent-page/AutomationScheduleDialog.js.map +1 -0
- package/dist/client/agent-page/TimezoneSelect.d.ts +17 -0
- package/dist/client/agent-page/TimezoneSelect.d.ts.map +1 -0
- package/dist/client/agent-page/TimezoneSelect.js +107 -0
- package/dist/client/agent-page/TimezoneSelect.js.map +1 -0
- package/dist/client/agent-page/use-jobs.d.ts +20 -7
- package/dist/client/agent-page/use-jobs.d.ts.map +1 -1
- package/dist/client/agent-page/use-jobs.js +24 -4
- package/dist/client/agent-page/use-jobs.js.map +1 -1
- package/dist/client/org/OrgSwitcher.d.ts +8 -1
- package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
- package/dist/client/org/OrgSwitcher.js +8 -2
- package/dist/client/org/OrgSwitcher.js.map +1 -1
- package/dist/client/settings/AccountSettingsCard.d.ts.map +1 -1
- package/dist/client/settings/AccountSettingsCard.js +10 -9
- package/dist/client/settings/AccountSettingsCard.js.map +1 -1
- package/dist/client/settings/SchedulingTimezoneField.d.ts +10 -0
- package/dist/client/settings/SchedulingTimezoneField.d.ts.map +1 -0
- package/dist/client/settings/SchedulingTimezoneField.js +30 -0
- package/dist/client/settings/SchedulingTimezoneField.js.map +1 -0
- package/dist/client/settings/SettingsRow.d.ts +36 -0
- package/dist/client/settings/SettingsRow.d.ts.map +1 -0
- package/dist/client/settings/SettingsRow.js +15 -0
- package/dist/client/settings/SettingsRow.js.map +1 -0
- package/dist/client/settings/agent-settings-search.d.ts.map +1 -1
- package/dist/client/settings/agent-settings-search.js +1 -1
- package/dist/client/settings/agent-settings-search.js.map +1 -1
- package/dist/client/settings/index.d.ts +1 -0
- package/dist/client/settings/index.d.ts.map +1 -1
- package/dist/client/settings/index.js +1 -0
- package/dist/client/settings/index.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/identity.d.ts.map +1 -1
- package/dist/integrations/identity.js +2 -11
- package/dist/integrations/identity.js.map +1 -1
- package/dist/jobs/actions/list-automation-runs.d.ts +8 -0
- package/dist/jobs/actions/list-automation-runs.d.ts.map +1 -0
- package/dist/jobs/actions/list-automation-runs.js +29 -0
- package/dist/jobs/actions/list-automation-runs.js.map +1 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts +2 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts.map +1 -1
- package/dist/jobs/actions/list-recurring-jobs.js +20 -7
- package/dist/jobs/actions/list-recurring-jobs.js.map +1 -1
- package/dist/jobs/actions/manage-recurring-job.d.ts +2 -0
- package/dist/jobs/actions/manage-recurring-job.js +30 -9
- package/dist/jobs/actions/manage-recurring-job.js.map +1 -1
- package/dist/jobs/background-automation-runner.d.ts.map +1 -1
- package/dist/jobs/background-automation-runner.js +60 -0
- package/dist/jobs/background-automation-runner.js.map +1 -1
- package/dist/jobs/cron.d.ts +16 -2
- package/dist/jobs/cron.d.ts.map +1 -1
- package/dist/jobs/cron.js +43 -6
- package/dist/jobs/cron.js.map +1 -1
- package/dist/jobs/frontmatter.d.ts +12 -0
- package/dist/jobs/frontmatter.d.ts.map +1 -1
- package/dist/jobs/frontmatter.js +8 -0
- package/dist/jobs/frontmatter.js.map +1 -1
- package/dist/jobs/run-history.d.ts +56 -0
- package/dist/jobs/run-history.d.ts.map +1 -0
- package/dist/jobs/run-history.js +169 -0
- package/dist/jobs/run-history.js.map +1 -0
- package/dist/jobs/scheduler.d.ts.map +1 -1
- package/dist/jobs/scheduler.js +51 -18
- package/dist/jobs/scheduler.js.map +1 -1
- package/dist/jobs/tools.d.ts.map +1 -1
- package/dist/jobs/tools.js +37 -6
- package/dist/jobs/tools.js.map +1 -1
- package/dist/localization/actions/get-localization-preference.d.ts +1 -2
- package/dist/localization/actions/get-localization-preference.d.ts.map +1 -1
- package/dist/localization/actions/get-localization-preference.js.map +1 -1
- package/dist/localization/actions/set-localization-preference.d.ts +3 -3
- package/dist/localization/actions/set-localization-preference.d.ts.map +1 -1
- package/dist/localization/actions/set-localization-preference.js +33 -8
- package/dist/localization/actions/set-localization-preference.js.map +1 -1
- package/dist/localization/default-messages.d.ts +25 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +25 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/localization/shared.d.ts +16 -1
- package/dist/localization/shared.d.ts.map +1 -1
- package/dist/localization/shared.js +22 -4
- package/dist/localization/shared.js.map +1 -1
- package/dist/localization/user-timezone.d.ts +11 -0
- package/dist/localization/user-timezone.d.ts.map +1 -0
- package/dist/localization/user-timezone.js +31 -0
- package/dist/localization/user-timezone.js.map +1 -0
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/org/app-roles-handlers.d.ts.map +1 -1
- package/dist/org/app-roles-handlers.js +1 -8
- package/dist/org/app-roles-handlers.js.map +1 -1
- package/dist/org/index.d.ts +1 -0
- package/dist/org/index.d.ts.map +1 -1
- package/dist/org/index.js +1 -0
- package/dist/org/index.js.map +1 -1
- package/dist/org/membership.d.ts +6 -0
- package/dist/org/membership.d.ts.map +1 -0
- package/dist/org/membership.js +16 -0
- package/dist/org/membership.js.map +1 -0
- package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/review/actions/create-review-comment.d.ts +32 -1
- package/dist/review/actions/create-review-comment.d.ts.map +1 -1
- package/dist/review/actions/create-review-comment.js +3 -1
- package/dist/review/actions/create-review-comment.js.map +1 -1
- package/dist/review/actions/reply-review-comment.d.ts +32 -1
- package/dist/review/actions/reply-review-comment.d.ts.map +1 -1
- package/dist/review/actions/reply-review-comment.js +3 -1
- package/dist/review/actions/reply-review-comment.js.map +1 -1
- package/dist/review/index.d.ts +1 -0
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/index.js +1 -0
- package/dist/review/index.js.map +1 -1
- package/dist/review/notifications.d.ts +24 -0
- package/dist/review/notifications.d.ts.map +1 -0
- package/dist/review/notifications.js +123 -0
- package/dist/review/notifications.js.map +1 -0
- package/dist/review/types.d.ts +5 -0
- package/dist/review/types.d.ts.map +1 -1
- package/dist/review/types.js.map +1 -1
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +4 -0
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/activity-notifications.d.ts +61 -0
- package/dist/server/activity-notifications.d.ts.map +1 -0
- package/dist/server/activity-notifications.js +103 -0
- package/dist/server/activity-notifications.js.map +1 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/app-name.d.ts +18 -0
- package/dist/server/app-name.d.ts.map +1 -1
- package/dist/server/app-name.js +31 -0
- package/dist/server/app-name.js.map +1 -1
- package/dist/server/better-auth-instance.js +4 -4
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/email-templates.d.ts +12 -0
- package/dist/server/email-templates.d.ts.map +1 -1
- package/dist/server/email-templates.js +57 -14
- package/dist/server/email-templates.js.map +1 -1
- package/dist/server/email.d.ts +12 -0
- package/dist/server/email.d.ts.map +1 -1
- package/dist/server/email.js +52 -5
- package/dist/server/email.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/sharing/index.d.ts +1 -0
- package/dist/sharing/index.d.ts.map +1 -1
- package/dist/sharing/index.js +1 -0
- package/dist/sharing/index.js.map +1 -1
- package/dist/sharing/recipients.d.ts +31 -0
- package/dist/sharing/recipients.d.ts.map +1 -0
- package/dist/sharing/recipients.js +40 -0
- package/dist/sharing/recipients.js.map +1 -0
- package/dist/templates/chat/app/routes/settings.tsx +14 -22
- package/dist/templates/chat/vitest.config.ts +9 -0
- package/dist/triggers/actions/list-automations.d.ts +2 -0
- package/dist/triggers/actions/list-automations.d.ts.map +1 -1
- package/dist/triggers/actions/list-automations.js +19 -8
- package/dist/triggers/actions/list-automations.js.map +1 -1
- package/dist/triggers/actions/manage-automation.d.ts +6 -0
- package/dist/triggers/actions/manage-automation.js +12 -6
- package/dist/triggers/actions/manage-automation.js.map +1 -1
- package/dist/triggers/actions.d.ts.map +1 -1
- package/dist/triggers/actions.js +15 -4
- package/dist/triggers/actions.js.map +1 -1
- package/dist/triggers/dispatcher.d.ts.map +1 -1
- package/dist/triggers/dispatcher.js +19 -17
- package/dist/triggers/dispatcher.js.map +1 -1
- package/dist/triggers/routes.d.ts.map +1 -1
- package/dist/triggers/routes.js +6 -6
- package/dist/triggers/routes.js.map +1 -1
- package/dist/vitest-config.d.ts +10 -0
- package/dist/vitest-config.d.ts.map +1 -0
- package/dist/vitest-config.js +55 -0
- package/dist/vitest-config.js.map +1 -0
- package/package.json +5 -4
- package/src/automations/service.ts +41 -4
- package/src/cli/mcp-config-writers.ts +27 -21
- package/src/client/agent-page/AgentJobsTab.tsx +187 -85
- package/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
- package/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
- package/src/client/agent-page/TimezoneSelect.tsx +241 -0
- package/src/client/agent-page/use-jobs.ts +50 -10
- package/src/client/org/OrgSwitcher.tsx +26 -0
- package/src/client/settings/AccountSettingsCard.tsx +35 -31
- package/src/client/settings/SchedulingTimezoneField.tsx +71 -0
- package/src/client/settings/SettingsRow.tsx +113 -0
- package/src/client/settings/agent-settings-search.ts +2 -1
- package/src/client/settings/index.ts +6 -0
- package/src/integrations/identity.ts +2 -12
- package/src/jobs/actions/list-automation-runs.ts +33 -0
- package/src/jobs/actions/list-recurring-jobs.ts +27 -6
- package/src/jobs/actions/manage-recurring-job.ts +41 -9
- package/src/jobs/background-automation-runner.ts +88 -0
- package/src/jobs/cron.ts +52 -6
- package/src/jobs/frontmatter.ts +20 -0
- package/src/jobs/run-history.ts +242 -0
- package/src/jobs/scheduler.ts +73 -18
- package/src/jobs/tools.ts +54 -6
- package/src/localization/actions/get-localization-preference.ts +2 -2
- package/src/localization/actions/set-localization-preference.ts +49 -11
- package/src/localization/default-messages.ts +27 -0
- package/src/localization/shared.ts +39 -6
- package/src/localization/user-timezone.ts +36 -0
- package/src/org/app-roles-handlers.ts +1 -9
- package/src/org/index.ts +2 -0
- package/src/org/membership.ts +18 -0
- package/src/review/actions/create-review-comment.ts +4 -1
- package/src/review/actions/reply-review-comment.ts +4 -1
- package/src/review/index.ts +5 -0
- package/src/review/notifications.ts +163 -0
- package/src/review/types.ts +7 -0
- package/src/server/action-discovery.ts +4 -0
- package/src/server/activity-notifications.ts +174 -0
- package/src/server/app-name.ts +31 -0
- package/src/server/better-auth-instance.ts +4 -4
- package/src/server/email-templates.ts +70 -14
- package/src/server/email.ts +66 -3
- package/src/server/index.ts +10 -0
- package/src/sharing/index.ts +5 -0
- package/src/sharing/recipients.ts +67 -0
- package/src/templates/chat/app/routes/settings.tsx +14 -22
- package/src/templates/chat/vitest.config.ts +9 -0
- package/src/triggers/actions/list-automations.ts +27 -8
- package/src/triggers/actions/manage-automation.ts +17 -6
- package/src/triggers/actions.ts +16 -4
- package/src/triggers/dispatcher.ts +44 -30
- package/src/triggers/routes.ts +19 -6
- 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
|
|
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
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
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(
|
|
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.
|
|
2999
|
-
cookie.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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<
|
|
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
|
-
|
|
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
|
}
|