Skip to content

Import Fathom recording media asynchronously - #25433

Open
ehconitin wants to merge 1 commit into
ehco/fathom-media-foundationfrom
ehco/fathom-async-media-import
Open

Import Fathom recording media asynchronously#25433
ehconitin wants to merge 1 commit into
ehco/fathom-media-foundationfrom
ehco/fathom-async-media-import

Conversation

@ehconitin

@ehconitin ehconitin commented Sep 4, 2026

Copy link
Copy Markdown
Member

Stack

  1. Add Fathom media storage foundation #25432 — Add Fathom media storage foundation
  2. Import Fathom recording media asynchronously #25433 — Import Fathom recording media asynchronously
  3. Reconcile stale Fathom media imports #25434 — Reconcile stale Fathom media imports

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

  • Add a request job that creates a Fathom download and imports an immediately available file.
  • Add a generation-fenced poll job with bounded exponential backoff for video generation.
  • Hold newly created CallRecordings in PROCESSING until transcript and media are both terminal, without allowing ordinary re-syncs to downgrade an already completed record.
  • Enqueue media from webhook, manual sync, and backfill paths while sharing one per-account pacing schedule with existing Fathom imports.
  • Use an atomic expiring claim to prevent overlapping transfers, and reschedule work that encounters a live claim.
  • Keep download ownership with the connection that created the private download; replace the generation when ownership moves.
  • Treat 403, 422, provider generation failures, empty files, and the size cap as terminal outcomes. Expired generations and exhausted polling remain recoverable for the reconciler.
  • Preserve the original error as the cause of retryable transfer, storage, and enqueue failures.
  • Make Sync Fathom Call the 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 tests
  • tsgo --noEmit -p tsconfig.spec.json
  • oxlint -c .oxlintrc.json src
  • twenty 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.

@ehconitin ehconitin changed the title ehco/fathom async media import Import Fathom recording media asynchronously Sep 4, 2026
@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds asynchronous Fathom recording-media generation, polling, upload checkpointing, failure settlement, and shared per-account job pacing.

  • Adds request and polling logic functions for Fathom video/audio imports.
  • Keeps Call Recordings in Processing until transcript and media are settled.
  • Adds claim, retry, checkpoint, failure-reason, and scheduling utilities.
  • Extends webhook, backfill, and manual sync flows to manage media imports.
  • The review found gaps affecting existing recordings, terminal polling states, and scheduler concurrency/continuity.

Confidence Score: 1/5

The 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

Filename Overview
packages/twenty-apps/public/fathom/src/logic-functions/fathom-import-media-download.ts Implements polling and media application, but expired and exhausted downloads can leave recordings indefinitely unsettled.
packages/twenty-apps/public/fathom/src/logic-functions/fathom-request-media-download.ts Starts or resumes Fathom media generation, with the same stranded-state behavior for immediately expired downloads.
packages/twenty-apps/public/fathom/src/logic-functions/utils/build-fathom-call-recording-upsert-fields.util.ts Computes media ownership and retry fields but excludes existing media-less recordings from import requests.
packages/twenty-apps/public/fathom/src/logic-functions/utils/reserve-fathom-import-slots.util.ts Generalizes per-account pacing, but reservation is non-atomic and its renamed persistent key loses deployment continuity.
packages/twenty-apps/public/fathom/src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util.ts Integrates media scheduling into sync while relying on an enqueue predicate that omits existing media-less records.
packages/twenty-apps/public/fathom/src/logic-functions/utils/apply-fathom-media-download.util.ts Applies completed media, resumes uploaded checkpoints, and settles explicit unavailable outcomes.

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]
Loading

Reviews (1): Last reviewed commit: "Import Fathom recording media asynchrono..." | Re-trigger Greptile

Comment on lines +71 to +72
isMediaDownloadRequestNeeded:
isRetryingSettledMedia || isReplacingActiveDownload,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +154 to +162
if (applyResult.outcome === 'expired') {
await updateFathomMediaDownloadId({
coreApiClient,
callRecordingId,
downloadId: null,
});

return { success: true, outcome: 'expired' };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Schedule Rename Breaks Pacing

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.

Comment on lines +21 to +27
const existingSchedule = await kv.get<FathomImportSchedule>(scheduleKey);
const scheduleStart = Math.max(
now + notBeforeDelayMilliseconds,
existingSchedule?.nextSlotAvailableAt ?? now,
);
const slotDelays = Array.from(
{ length: slotCount },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +200 to +207

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

@twenty-ci-bot-public

Copy link
Copy Markdown

🤖 PR Review

Check Result
🔍 Build safety ⏭️ skipped — external-only
🛡️ Security ✅ passed
🧭 Triage ⏭️ skipped — external-only
📐 Quality ✅ passed — 2 nit(s)
🚦 Auto-approve 👀 needs review — Complexity is medium

🛡️ Security Review

No high-severity vulnerabilities detected.


🚦 Auto-approve

🙋 Manual review recommended for the following reason(s):

  • Complexity is medium

  • 🧠 Complexity: medium

  • 📏 Size: +1280 / -124 lines across 35 file(s)


View details

Automated pre-review — human approval still required.

@twenty-ci-bot-public

Copy link
Copy Markdown

🟡 Quality review · 2 findings

Safe to merge — two non-blocking low-level nits

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.
Low-level — Line-by-line is clean — single-object params, zod-validated payloads, isDefined used throughout, no stray comments — except near-duplicate claim scaffolding across the two handlers and one === null that should be isDefined.

💬 2 inline comments on the diff.


Reviewed against the pr-review standard — high-level then low-level. Advisory; human review still required. Run details.

now: new Date(),
});

if (!isDefined(claim)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

View Feedback

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>
Suggested change
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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant