Import Fathom recording media asynchronously - #25433
Conversation
Greptile SummaryThis PR adds asynchronous Fathom recording-media generation, polling, upload checkpointing, failure settlement, and shared per-account job pacing.
Confidence Score: 1/5The PR is not safe to merge until existing recordings can enter the media flow, terminal polling states are settled or retried, and per-account scheduling preserves reliable pacing. Four independent workflow defects can omit media imports, strand recordings in Processing, or allow overlapping provider jobs despite the intended pacing guarantees. Files Needing Attention: packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts; packages/twenty-apps/public/fathom/src/logic-functions/fathom-request-media-download.ts; packages/twenty-apps/public/fathom/src/logic-functions/utils/build-fathom-call-recording-upsert-fields.util.ts; packages/twenty-apps/public/fathom/src/logic-functions/utils/reserve-fathom-import-slots.util.ts; packages/twenty-apps/public/fathom/src/logic-functions/utils/get-fathom-import-schedule-key.util.ts Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
S[Webhook, backfill, or manual sync] --> U[Upsert Call Recording]
U --> D{New record or media request needed?}
D -- Yes --> R[Reserve per-account slot]
D -- No --> X[No media job scheduled]
R --> Q[Request Fathom download]
Q --> A{Download status}
A -- Ready --> F[Stream and upload media]
A -- Processing --> P[Reserve delayed polling slot]
P --> I[Poll Fathom download]
I --> A
A -- Failed or unavailable --> E[Record failure reason]
F --> C[Settle media and complete recording]
E --> C
A -- Expired or poll budget exhausted --> Z[Download ID cleared without settlement]
Reviews (1): Last reviewed commit: "Import Fathom recording media asynchrono..." | Re-trigger Greptile |
| isMediaDownloadRequestNeeded: | ||
| isRetryingSettledMedia || isReplacingActiveDownload, |
There was a problem hiding this comment.
Existing Recordings Skip Media
Recordings created before this feature have no media, failure reason, or active download. This condition only requests media when retrying a recorded failure or replacing an active download, so webhook, backfill, and explicit Sync Fathom Call runs update these existing records without ever starting their media import.
| if (applyResult.outcome === 'expired') { | ||
| await updateFathomMediaDownloadId({ | ||
| coreApiClient, | ||
| callRecordingId, | ||
| downloadId: null, | ||
| }); | ||
|
|
||
| return { success: true, outcome: 'expired' }; | ||
| } |
There was a problem hiding this comment.
Terminal Polls Remain Processing
When a download expires or exhausts its polling budget, the handler only clears the download ID and returns success. It does not record a failure or schedule a replacement, so the recording remains in Processing with no active job. Later syncs cannot restart it because both the failure reason and download ID are absent. The same behavior also occurs in the poll-budget-exhausted branch and the request handler's expired branch.
| @@ -0,0 +1,3 @@ | |||
| export const getFathomImportScheduleKey = ( | |||
| connectedAccountId: string, | |||
| ): string => `fathom-import-schedule:${connectedAccountId}`; | |||
There was a problem hiding this comment.
The persistent per-account schedule key changes from fathom-backfill-schedule to fathom-import-schedule without carrying over the previous reservation. If delayed backfill jobs remain queued during deployment, new backfill and media reservations start immediately under the empty new key and overlap those jobs, defeating the provider pacing this schedule is meant to enforce.
| const existingSchedule = await kv.get<FathomImportSchedule>(scheduleKey); | ||
| const scheduleStart = Math.max( | ||
| now + notBeforeDelayMilliseconds, | ||
| existingSchedule?.nextSlotAvailableAt ?? now, | ||
| ); | ||
| const slotDelays = Array.from( | ||
| { length: slotCount }, |
There was a problem hiding this comment.
Concurrent Reservations Share Slots
Slot reservation reads the current KV value and writes the replacement in separate operations. Concurrent webhook or backfill imports for the same connected account can therefore read the same next slot and enqueue provider calls for the same time, bypassing the shared pacing mechanism and increasing rate-limit failures.
|
|
||
| export default defineLogicFunction({ | ||
| universalIdentifier: FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER, | ||
| name: 'fathom-import-media-download', | ||
| description: | ||
| "Polls one Fathom recording download and streams the generated video or audio into the CallRecording's media fields once Fathom finishes generating it.", | ||
| timeoutSeconds: 900, | ||
| handler: fathomImportMediaDownloadHandler, |
There was a problem hiding this comment.
Default Exports Violate Convention
This new logic-function module uses a default export even though the repository's always-applied guidance requires named exports. The new request-media logic function follows the same pattern, making these modules inconsistent with the required export convention.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
🤖 PR Review
🛡️ Security Review✅ No high-severity vulnerabilities detected. 🚦 Auto-approve🙋 Manual review recommended for the following reason(s):
Automated pre-review — human approval still required. |
🟡 Quality review · 2 findings
High-level — Self-contained fathom-app feature (media import → CallRecording); no shared/low-level-layer touches, CallRecording media fields already exist in base as foundation so the db→runtime rollout order is correct, and the backfill→import scheduler rename is a coherent part of the one logical change. 💬 2 inline comments on the diff. Reviewed against the |
| now: new Date(), | ||
| }); | ||
|
|
||
| if (!isDefined(claim)) { |
There was a problem hiding this comment.
🟡 Nit · Low-level · reuse / DRY
The claim → resolve-target → skip-in-progress → try/finally-release scaffolding is near-duplicated between fathom-request-media-download.ts and fathom-import-media-download.ts
The two handlers repeat the same ~20-line contention-guard and claim-release structure, so a change to the claim protocol must be made in both. Extract the claim/release lifecycle into a shared wrapper (e.g. withFathomMediaImportClaim) that runs the divergent body inside the try.
| id: callRecordingId, | ||
| data: { | ||
| fathomMediaDownloadId: downloadId, | ||
| ...(downloadId === null ? { fathomMediaUploadCheckpoint: null } : {}), |
There was a problem hiding this comment.
🟡 Nit · Low-level · typing (isDefined over manual nullish checks)
downloadId === null is a manual nullish check where the standard calls for isDefined
The All-typing rule prefers the isDefined guard over hand-written null comparisons. Use !isDefined(downloadId) for the checkpoint-clear branch.
There was a problem hiding this comment.
8 issues found across 35 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/twenty-apps/public/fathom/src/logic-functions/utils/get-fathom-media-failure-reason-for-error.util.ts">
<violation number="1" location="packages/twenty-apps/public/fathom/src/logic-functions/utils/get-fathom-media-failure-reason-for-error.util.ts:8">
P3: This new util has no unit test, while every sibling function in `src/logic-functions/utils/` (is-fathom-not-found-error, is-transient-fathom-error, is-fathom-call-recording-import-complete, get-fathom-media-download-poll-delay) ships a co-located `__tests__` case. The status-code → failure-reason mapping (403 → download_forbidden, 422 → no_downloadable_media) and the non-FathomError fallback are exactly the branch logic that should be pinned down, and it gates the async media import path (both `fathom-request-media-download` and `fathom-import-media-download` skip/terminate on these reasons). Add a test covering FathomError 403/422, a FathomError with another status, and a non-FathomError.</violation>
</file>
<file name="packages/twenty-apps/public/fathom/src/logic-functions/utils/reserve-fathom-import-slots.util.ts">
<violation number="1" location="packages/twenty-apps/public/fathom/src/logic-functions/utils/reserve-fathom-import-slots.util.ts:21">
P1: When two Fathom jobs reserve slots for the same account concurrently, both can read the old schedule before either `set` runs and receive identical delays. Make the schedule update atomic, using a compare-and-set/transaction or a per-account lock, so every reservation advances the shared schedule.</violation>
</file>
<file name="packages/twenty-apps/public/fathom/src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util.ts">
<violation number="1" location="packages/twenty-apps/public/fathom/src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util.ts:14">
P2: This uses 200 only as a chunk size: callers can pass more than 200 payloads and enqueue an unbounded number of jobs, while `[]` resolves successfully without enqueueing anything. Validate the payload count before chunking and reject values outside the 1–200 range.
(Based on your team's feedback about explicit enqueue batch cardinality limits.)</violation>
</file>
<file name="packages/twenty-apps/public/fathom/src/logic-functions/fathom-request-media-download.ts">
<violation number="1" location="packages/twenty-apps/public/fathom/src/logic-functions/fathom-request-media-download.ts:115">
P2: When Fathom creates the download but `updateFathomMediaDownloadId` fails, the retry creates another download because the first ID was never persisted. Make the create-and-record transition idempotent, or reconcile the outstanding Fathom download before issuing another create, to avoid orphaned generations and repeated media work.</violation>
</file>
<file name="packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts">
<violation number="1" location="packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts:81">
P3: In the claim-undefined path the handler re-enqueues a poll with the stale `expectedDownloadId` without verifying it still equals `target.downloadId`. If a newer download replaced the expected one (or the target has no active download), this schedules a poll that will run only to immediately report "media download was replaced" / "no active download", doing a redundant `resolveFathomMediaImportTarget` round-trip. Skip the re-enqueue when `target.downloadId !== expectedDownloadId` or `target.downloadId` is undefined.</violation>
<violation number="2" location="packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts:146">
P1: If another connection replaces the download during the Fathom fetch or upload, this handler commits the stale download because the claim does not cover sync/upsert writes and `applyFathomMediaDownload` is unconditional. Make media application conditional on the current download/owner, or coordinate the sync replacement through the same claim before committing files.</violation>
<violation number="3" location="packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts:154">
P1: When Fathom reports an expired download or polling exhausts its budget, this handler only clears the download ID and leaves the CallRecording unsettled in `PROCESSING`. Settle both terminal paths through `recordFathomMediaFailure` with dedicated failure reasons so completion and future retry behavior have a consistent terminal state.</violation>
</file>
<file name="packages/twenty-apps/public/fathom/src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util.ts">
<violation number="1" location="packages/twenty-apps/public/fathom/src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util.ts:81">
P2: The media-download enqueue is gated on `upsertResult.created || isMediaDownloadRequestNeeded`, and both flags go false on every later call once the download job is absent. On the create path, if `enqueueFathomMediaDownloadRequest` throws (reserveFathomImportSlots does `kv` reads/writes and enqueueFathomJobsOrThrow does an `enqueueJobs` that throws when not enqueued), the recording was already created/updated with status PROCESSING and no failure state, and the thrown error propagates to the caller. The webhook platform then retries the webhook, but the retry finds the record exists (created=false) with no downloadId and no failureReason, so `isMediaDownloadRequestNeeded` is false and no download is ever re-enqueued. Because no reconciliation path exists in the codebase, the recording is stranded in PROCESSING forever. The retry path is worse: buildFathomCallRecordingUpsertFields clears `fathomMediaFailureReason`/`fathomMediaDownloadId`/`fathomMediaUploadCheckpoint` in the upsert *before* the enqueue is confirmed, destroying the only durable signal that would let a later webhook re-trigger the download. Enqueue (or reserve the failure state) before clearing the failure/download state, or keep a durable pending marker that later syncs re-check.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| }): Promise<{ slotDelays: number[]; continuationDelay: number }> => { | ||
| const now = Date.now(); | ||
| const scheduleKey = getFathomImportScheduleKey(connectedAccountId); | ||
| const existingSchedule = await kv.get<FathomImportSchedule>(scheduleKey); |
There was a problem hiding this comment.
P1: When two Fathom jobs reserve slots for the same account concurrently, both can read the old schedule before either set runs and receive identical delays. Make the schedule update atomic, using a compare-and-set/transaction or a per-account lock, so every reservation advances the shared schedule.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/utils/reserve-fathom-import-slots.util.ts, line 21:
<comment>When two Fathom jobs reserve slots for the same account concurrently, both can read the old schedule before either `set` runs and receive identical delays. Make the schedule update atomic, using a compare-and-set/transaction or a per-account lock, so every reservation advances the shared schedule.</comment>
<file context>
@@ -0,0 +1,41 @@
+}): Promise<{ slotDelays: number[]; continuationDelay: number }> => {
+ const now = Date.now();
+ const scheduleKey = getFathomImportScheduleKey(connectedAccountId);
+ const existingSchedule = await kv.get<FathomImportSchedule>(scheduleKey);
+ const scheduleStart = Math.max(
+ now + notBeforeDelayMilliseconds,
</file context>
| download, | ||
| }); | ||
|
|
||
| if (applyResult.outcome === 'expired') { |
There was a problem hiding this comment.
P1: When Fathom reports an expired download or polling exhausts its budget, this handler only clears the download ID and leaves the CallRecording unsettled in PROCESSING. Settle both terminal paths through recordFathomMediaFailure with dedicated failure reasons so completion and future retry behavior have a consistent terminal state.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts, line 154:
<comment>When Fathom reports an expired download or polling exhausts its budget, this handler only clears the download ID and leaves the CallRecording unsettled in `PROCESSING`. Settle both terminal paths through `recordFathomMediaFailure` with dedicated failure reasons so completion and future retry behavior have a consistent terminal state.</comment>
<file context>
@@ -0,0 +1,208 @@
+ download,
+ });
+
+ if (applyResult.outcome === 'expired') {
+ await updateFathomMediaDownloadId({
+ coreApiClient,
</file context>
| return { success: true, skipped: true, reason: failureReason }; | ||
| } | ||
|
|
||
| const applyResult = await applyFathomMediaDownload({ |
There was a problem hiding this comment.
P1: If another connection replaces the download during the Fathom fetch or upload, this handler commits the stale download because the claim does not cover sync/upsert writes and applyFathomMediaDownload is unconditional. Make media application conditional on the current download/owner, or coordinate the sync replacement through the same claim before committing files.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts, line 146:
<comment>If another connection replaces the download during the Fathom fetch or upload, this handler commits the stale download because the claim does not cover sync/upsert writes and `applyFathomMediaDownload` is unconditional. Make media application conditional on the current download/owner, or coordinate the sync replacement through the same claim before committing files.</comment>
<file context>
@@ -0,0 +1,208 @@
+ return { success: true, skipped: true, reason: failureReason };
+ }
+
+ const applyResult = await applyFathomMediaDownload({
+ coreApiClient,
+ callRecordingId,
</file context>
| }: Pick<EnqueueJobsInput, 'logicFunctionUniversalIdentifier' | 'delayMs'> & { | ||
| payloads: Record<string, unknown>[]; | ||
| }): Promise<void> => { | ||
| for (const payloadBatch of chunkIntoBatches( |
There was a problem hiding this comment.
P2: This uses 200 only as a chunk size: callers can pass more than 200 payloads and enqueue an unbounded number of jobs, while [] resolves successfully without enqueueing anything. Validate the payload count before chunking and reject values outside the 1–200 range.
(Based on your team's feedback about explicit enqueue batch cardinality limits.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util.ts, line 14:
<comment>This uses 200 only as a chunk size: callers can pass more than 200 payloads and enqueue an unbounded number of jobs, while `[]` resolves successfully without enqueueing anything. Validate the payload count before chunking and reject values outside the 1–200 range.
(Based on your team's feedback about explicit enqueue batch cardinality limits.) </comment>
<file context>
@@ -0,0 +1,30 @@
+}: Pick<EnqueueJobsInput, 'logicFunctionUniversalIdentifier' | 'delayMs'> & {
+ payloads: Record<string, unknown>[];
+}): Promise<void> => {
+ for (const payloadBatch of chunkIntoBatches(
+ payloads,
+ MAX_FATHOM_JOBS_PER_ENQUEUE,
</file context>
| for (const payloadBatch of chunkIntoBatches( | |
| if ( | |
| payloads.length === 0 || | |
| payloads.length > MAX_FATHOM_JOBS_PER_ENQUEUE | |
| ) { | |
| throw new Error( | |
| `Fathom enqueue requires between 1 and ${MAX_FATHOM_JOBS_PER_ENQUEUE} payloads`, | |
| ); | |
| } | |
| for (const payloadBatch of chunkIntoBatches( |
| let download: RecordingDownload; | ||
|
|
||
| try { | ||
| download = await fathomClient.createRecordingDownload({ |
There was a problem hiding this comment.
P2: When Fathom creates the download but updateFathomMediaDownloadId fails, the retry creates another download because the first ID was never persisted. Make the create-and-record transition idempotent, or reconcile the outstanding Fathom download before issuing another create, to avoid orphaned generations and repeated media work.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/fathom-request-media-download.ts, line 115:
<comment>When Fathom creates the download but `updateFathomMediaDownloadId` fails, the retry creates another download because the first ID was never persisted. Make the create-and-record transition idempotent, or reconcile the outstanding Fathom download before issuing another create, to avoid orphaned generations and repeated media work.</comment>
<file context>
@@ -0,0 +1,194 @@
+ let download: RecordingDownload;
+
+ try {
+ download = await fathomClient.createRecordingDownload({
+ recordingId: target.recordingId,
+ });
</file context>
| coreApiClient, | ||
| callRecordingId, | ||
| }); | ||
| if (upsertResult.created || isMediaDownloadRequestNeeded) { |
There was a problem hiding this comment.
P2: The media-download enqueue is gated on upsertResult.created || isMediaDownloadRequestNeeded, and both flags go false on every later call once the download job is absent. On the create path, if enqueueFathomMediaDownloadRequest throws (reserveFathomImportSlots does kv reads/writes and enqueueFathomJobsOrThrow does an enqueueJobs that throws when not enqueued), the recording was already created/updated with status PROCESSING and no failure state, and the thrown error propagates to the caller. The webhook platform then retries the webhook, but the retry finds the record exists (created=false) with no downloadId and no failureReason, so isMediaDownloadRequestNeeded is false and no download is ever re-enqueued. Because no reconciliation path exists in the codebase, the recording is stranded in PROCESSING forever. The retry path is worse: buildFathomCallRecordingUpsertFields clears fathomMediaFailureReason/fathomMediaDownloadId/fathomMediaUploadCheckpoint in the upsert before the enqueue is confirmed, destroying the only durable signal that would let a later webhook re-trigger the download. Enqueue (or reserve the failure state) before clearing the failure/download state, or keep a durable pending marker that later syncs re-check.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util.ts, line 81:
<comment>The media-download enqueue is gated on `upsertResult.created || isMediaDownloadRequestNeeded`, and both flags go false on every later call once the download job is absent. On the create path, if `enqueueFathomMediaDownloadRequest` throws (reserveFathomImportSlots does `kv` reads/writes and enqueueFathomJobsOrThrow does an `enqueueJobs` that throws when not enqueued), the recording was already created/updated with status PROCESSING and no failure state, and the thrown error propagates to the caller. The webhook platform then retries the webhook, but the retry finds the record exists (created=false) with no downloadId and no failureReason, so `isMediaDownloadRequestNeeded` is false and no download is ever re-enqueued. Because no reconciliation path exists in the codebase, the recording is stranded in PROCESSING forever. The retry path is worse: buildFathomCallRecordingUpsertFields clears `fathomMediaFailureReason`/`fathomMediaDownloadId`/`fathomMediaUploadCheckpoint` in the upsert *before* the enqueue is confirmed, destroying the only durable signal that would let a later webhook re-trigger the download. Enqueue (or reserve the failure state) before clearing the failure/download state, or keep a durable pending marker that later syncs re-check.</comment>
<file context>
@@ -49,14 +60,30 @@ export const syncFathomMeetingToCallRecording = async ({
+ coreApiClient,
+ callRecordingId,
});
+ if (upsertResult.created || isMediaDownloadRequestNeeded) {
+ await enqueueFathomMediaDownloadRequest({
+ callRecordingId,
</file context>
| @@ -0,0 +1,24 @@ | |||
| import { FathomError } from 'fathom-typescript/sdk/models/errors'; | |||
There was a problem hiding this comment.
P3: This new util has no unit test, while every sibling function in src/logic-functions/utils/ (is-fathom-not-found-error, is-transient-fathom-error, is-fathom-call-recording-import-complete, get-fathom-media-download-poll-delay) ships a co-located __tests__ case. The status-code → failure-reason mapping (403 → download_forbidden, 422 → no_downloadable_media) and the non-FathomError fallback are exactly the branch logic that should be pinned down, and it gates the async media import path (both fathom-request-media-download and fathom-import-media-download skip/terminate on these reasons). Add a test covering FathomError 403/422, a FathomError with another status, and a non-FathomError.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/utils/get-fathom-media-failure-reason-for-error.util.ts, line 8:
<comment>This new util has no unit test, while every sibling function in `src/logic-functions/utils/` (is-fathom-not-found-error, is-transient-fathom-error, is-fathom-call-recording-import-complete, get-fathom-media-download-poll-delay) ships a co-located `__tests__` case. The status-code → failure-reason mapping (403 → download_forbidden, 422 → no_downloadable_media) and the non-FathomError fallback are exactly the branch logic that should be pinned down, and it gates the async media import path (both `fathom-request-media-download` and `fathom-import-media-download` skip/terminate on these reasons). Add a test covering FathomError 403/422, a FathomError with another status, and a non-FathomError.</comment>
<file context>
@@ -0,0 +1,24 @@
+const FORBIDDEN_STATUS_CODE = 403;
+const UNPROCESSABLE_STATUS_CODE = 422;
+
+export const getFathomMediaFailureReasonForError = (
+ error: unknown,
+): string | undefined => {
</file context>
| await enqueueFathomMediaDownloadPoll({ | ||
| callRecordingId, | ||
| connectedAccountId: target.connectedAccountId, | ||
| downloadId: expectedDownloadId, |
There was a problem hiding this comment.
P3: In the claim-undefined path the handler re-enqueues a poll with the stale expectedDownloadId without verifying it still equals target.downloadId. If a newer download replaced the expected one (or the target has no active download), this schedules a poll that will run only to immediately report "media download was replaced" / "no active download", doing a redundant resolveFathomMediaImportTarget round-trip. Skip the re-enqueue when target.downloadId !== expectedDownloadId or target.downloadId is undefined.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts, line 81:
<comment>In the claim-undefined path the handler re-enqueues a poll with the stale `expectedDownloadId` without verifying it still equals `target.downloadId`. If a newer download replaced the expected one (or the target has no active download), this schedules a poll that will run only to immediately report "media download was replaced" / "no active download", doing a redundant `resolveFathomMediaImportTarget` round-trip. Skip the re-enqueue when `target.downloadId !== expectedDownloadId` or `target.downloadId` is undefined.</comment>
<file context>
@@ -0,0 +1,208 @@
+ await enqueueFathomMediaDownloadPoll({
+ callRecordingId,
+ connectedAccountId: target.connectedAccountId,
+ downloadId: expectedDownloadId,
+ attempt,
+ notBeforeDelayMilliseconds:
</file context>
Stack
This stack supersedes #25337. This PR is based on #25432 and should be reviewed as one layer.
Why
Fathom's recording download is asynchronous: audio can be ready immediately, while video generation requires polling. This PR activates the media lifecycle on top of the storage foundation.
What changed
PROCESSINGuntil transcript and media are both terminal, without allowing ordinary re-syncs to downgrade an already completed record.Sync Fathom Callthe explicit way to retry a previously settled media failure.Review boundary
This PR is the complete request/import lifecycle. It does not contain the scheduled stale-work scan, which is isolated in #25434.
The diff is 1,095 non-test additions. Keeping the existing backfill pacing migration in this layer avoids an unsafe intermediate deployment where backfills and media jobs use separate Fathom budgets.
Verification
vitest run --config vitest.unit.config.ts: 17 files, 60 teststsgo --noEmit -p tsconfig.spec.jsonoxlint -c .oxlintrc.json srctwenty dev:build .Tests cover payload validation, ownership and retry transitions, completion rules, polling cadence, and the real streaming path. Mock-heavy handler and service tests from #25337 were removed.
Deployment
The logic functions are idempotent around deterministic CallRecording IDs, generation IDs, claims, and completed-upload checkpoints. The next PR adds the reconciliation safety net for work lost after all platform retries.