Repository navigation
feat: guided Loom bulk import with live progress, 2,000 videos per CSV - #2428
richiemcilroy wants to merge 39 commits into
Conversation
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Imports are created in one request (up to 2,000 rows), checked against Loom with aliased GraphQL batches, then dispatched a few videos at a time from a durable workflow. Each finished video refills the window, so the browser no longer drives batches and the tab can be closed. Imported Caps keep their Loom title, length, size and original recording date, and the upload row now records the raw file key so retries and stalled-pipeline recovery work for Loom imports. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
The media-server webhook queued transcription for every finished web MP4, and stalled-pipeline recovery picked up any recent video without a transcript, so bulk Loom imports paid for transcription and AI on videos nobody opened. Imported Loom videos now wait for the share page or embed to start them on first view, and the webhook refills the import window instead. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Hand-drawn walkthrough of the import, a drop zone that also takes pasted links, automatic column detection with a review step, a live job page with a virtualized list of every video, and an upgrade panel that lets free users check their whole library before upgrading. Checkout returns to the job and starts it. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Polls now return only rows changed since the previous poll, the first render ships one screen of rows and loads the rest right after, and the list holds off loading new thumbnails until scrolling settles. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Job actions live in the status card instead of crowding the list toolbar, accent tints use theme-safe colors so the active stage reads in dark mode, and relative times no longer trip hydration warnings. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
The Loom import job workflow is now covered by the workflow runtime boundary test. It checks Pro through a shared workflow-safe helper instead of @cap/utils, and dispatch drops the @cap/utils short-link call, which was a no-op stub. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Each import checks every link with Loom from our servers, so cap new jobs at 30 per person per organization each hour. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Resolve storage for the next videos before taking the job lock, retry a failed workflow start and roll a post-upgrade start back to waiting, always release the busy state on the job page, guard the create call, and retry loading the full list if it fails. Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
@greptileai please review |
| await db() | ||
| .update(videoUploads) | ||
| .set({ | ||
| processingMessage: "Retrying Loom import...", | ||
| rawFileKey, | ||
| updatedAt: now, | ||
| }) | ||
| .where(eq(videoUploads.videoId, row.videoId)); | ||
| await start(importLoomVideoWorkflow, [ |
There was a problem hiding this comment.
Recovery starts duplicate copies
Two overlapping recovery requests can select the same stale upload. This update checks only videoId, so both requests can restart it. Bulk workflows have no per-video claim, and the media server creates a separate job for each request. Those jobs write the same output files and can interfere with each other's progress.
Recheck the observed phase and timestamp in the update. Start the workflow only when that update successfully claims the row.
Knowledge Base Used: Web API and domain services
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/loom-import/recovery.ts
Line: 82-90
Comment:
**Recovery starts duplicate copies**
Two overlapping recovery requests can select the same stale upload. This update checks only `videoId`, so both requests can restart it. Bulk workflows have no per-video claim, and the media server creates a separate job for each request. Those jobs write the same output files and can interfere with each other's progress.
Recheck the observed phase and timestamp in the update. Start the workflow only when that update successfully claims the row.
**Knowledge Base Used:** [Web API and domain services](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/web-api-and-domain-services.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| and( | ||
| eq(loomImportJobItems.videoId, Video.VideoId.make(videoId)), | ||
| eq(loomImportJobItems.status, "importing"), | ||
| ), |
There was a problem hiding this comment.
Successful retries stay failed
dispatchLoomImportForVideo ignores failed items. The existing video retry button restarts the upload without changing the bulk item, so completion never clears its old failure. After the upload row disappears, the results still show Failed and omit the new Cap link. The bulk retry button cannot fix that state either.
Settle items retried from the video page as well, including those in jobs already marked completed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/loom-import/dispatch.ts
Line: 471-474
Comment:
**Successful retries stay failed**
`dispatchLoomImportForVideo` ignores failed items. The existing video retry button restarts the upload without changing the bulk item, so completion never clears its old failure. After the upload row disappears, the results still show Failed and omit the new Cap link. The bulk retry button cannot fix that state either.
Settle items retried from the video page as well, including those in jobs already marked completed.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const apply = useCallback((snapshot: LoomImportSnapshot) => { | ||
| const map = itemsRef.current; | ||
| if (!map) return; | ||
| const merged = mergeLoomImportItems(map, orderRef.current, snapshot); | ||
| orderRef.current = merged.order; | ||
| cursorRef.current = Math.max(cursorRef.current, snapshot.cursor); |
There was a problem hiding this comment.
Older polls overwrite progress
apply accepts responses in arrival order. The initial full load, polling, visibility refresh, and action refresh can overlap, so an older response can overwrite newer rows or replace a completed job with an importing job. Keeping the larger cursor does not prevent that overwrite; later polls can skip the lost changes.
Order or serialize these requests, while ensuring the initial full load still supplies every row.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
Line: 84-89
Comment:
**Older polls overwrite progress**
`apply` accepts responses in arrival order. The initial full load, polling, visibility refresh, and action refresh can overlap, so an older response can overwrite newer rows or replace a completed job with an importing job. Keeping the larger cursor does not prevent that overwrite; later polls can skip the lost changes.
Order or serialize these requests, while ensuring the initial full load still supplies every row.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| useEffect(() => { | ||
| const element = ref.current; | ||
| if (!element) return; | ||
| const max = Math.max(0, count * ROW_HEIGHT - viewport); | ||
| if (element.scrollTop > max) { | ||
| element.scrollTop = max; | ||
| setScrollTop(max); | ||
| } | ||
| }, [count, viewport]); |
There was a problem hiding this comment.
Cleared searches leave blank rows
useVirtualWindow keeps its old scrollTop when a search or filter returns no rows. The scrolling element disappears, then returns at the top when results return. This effect does not sync the saved position because the new element is already at zero.
After scrolling deep into a large import, searching for no matches and clearing the search leaves a blank list until another scroll. Reset or sync scrollTop when the element is recreated.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
Line: 74-82
Comment:
**Cleared searches leave blank rows**
`useVirtualWindow` keeps its old `scrollTop` when a search or filter returns no rows. The scrolling element disappears, then returns at the top when results return. This effect does not sync the saved position because the new element is already at zero.
After scrolling deep into a large import, searching for no matches and clearing the search leaves a blank list until another scroll. Reset or sync `scrollTop` when the element is recreated.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| items: full | ||
| ? all | ||
| : all.filter( | ||
| (item) => item.v > (since as number) - LOOM_IMPORT_CURSOR_OVERLAP_MS, | ||
| ), |
There was a problem hiding this comment.
getLoomImportSnapshot selects changed rows using timestamps that exclude the job, but a row's display status depends on the job status too. If preparation takes longer than the three-second overlap, already assigned rows can stay displayed as Ready after the job starts importing, while the counts say Queued. Later rows in a 2,000-video job can stay wrong for a long time.
Send affected rows when the job status changes, or derive their display status from the latest job on the client.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/loom-import/snapshot.ts
Line: 154-158
Comment:
**Queued rows still show Ready**
`getLoomImportSnapshot` selects changed rows using timestamps that exclude the job, but a row's display status depends on the job status too. If preparation takes longer than the three-second overlap, already assigned rows can stay displayed as Ready after the job starts importing, while the counts say Queued. Later rows in a 2,000-video job can stay wrong for a long time.
Send affected rows when the job status changes, or derive their display status from the latest job on the client.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const headerless = first.some( | ||
| (cell) => /loom\.com/i.test(cell) && extractLoomVideoId(cell) !== null, | ||
| ); |
There was a problem hiding this comment.
parseCsv recognizes a headerless file only when its first row contains loom.com. Bare Loom IDs are otherwise valid inputs, so uploading a list of 32-character IDs silently treats the first video as a header and drops it. A one-video file is rejected as having no rows.
Include valid bare IDs in headerless detection and add a file-upload test.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/loom-import/csv.ts
Line: 198-200
Comment:
**First uploaded ID disappears**
`parseCsv` recognizes a headerless file only when its first row contains `loom.com`. Bare Loom IDs are otherwise valid inputs, so uploading a list of 32-character IDs silently treats the first video as a header and drops it. A one-video file is rejected as having no rows.
Include valid bare IDs in headerless detection and add a file-upload test.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.…ide the job Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
@greptileai please review |
|
hey @greptileai please re-review |
… length Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
hey @greptileai please re-review |
| ); | ||
| repairedTempFile = repairedFile; | ||
| updateJob(jobId, { metadata }); | ||
| extendJobLifetimeForMedia(jobId, metadata.duration); |
There was a problem hiding this comment.
processVideoAsync now lets long regular uploads run for up to three hours, but processVideoWorkflow still calls waitForVideoProcessing(videoId) with its one-hour default. If an upload needs more than an hour, the workflow marks it failed while the media server is still copying it.
A later completion webhook can clear the error, but the stopped workflow skips its raw-file cleanup. Give regular uploads a matching wait budget, as the Loom workflow now does.
Knowledge Base Used: Media-server runtime service
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/routes/video.ts
Line: 1350
Comment:
**Long uploads fail too soon**
`processVideoAsync` now lets long regular uploads run for up to three hours, but `processVideoWorkflow` still calls `waitForVideoProcessing(videoId)` with its one-hour default. If an upload needs more than an hour, the workflow marks it failed while the media server is still copying it.
A later completion webhook can clear the error, but the stopped workflow skips its raw-file cleanup. Give regular uploads a matching wait budget, as the Loom workflow now does.
**Knowledge Base Used:** [Media-server runtime service](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/media-server-and-web-cluster.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.…g videos Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
…progress Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
hey @greptileai please re-review |
| if (job.progressWatched) { | ||
| const quietFor = now - (job.progressAt ?? job.createdAt); | ||
| if (isActivePhase(job.phase) && quietFor > JOB_PROGRESS_STALL_MS) { | ||
| console.warn( | ||
| `[job-manager] Marking job ${jobId} as error after ${Math.round(quietFor / 60000)}m without progress (phase=${job.phase}, age=${Math.round(age / 60000)}m)`, | ||
| ); | ||
| job.abortController?.abort(); |
There was a problem hiding this comment.
The new watchdog stops valid WebM uploads during validateVideoInput. That function fully decodes the recording and allows 45 minutes, but reports no progress. Its withJobHeartbeat wrapper updates only updatedAt, not progressAt.
If the decode takes more than 15 minutes, the watchdog aborts it as stalled even while FFmpeg is working. Track advancing frames during this check, or keep its existing bounded timeout outside the progress watchdog.
Knowledge Base Used: Media-server runtime service
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/job-manager.ts
Line: 607-613
Comment:
**Long WebM uploads fail early**
The new watchdog stops valid WebM uploads during `validateVideoInput`. That function fully decodes the recording and allows 45 minutes, but reports no progress. Its `withJobHeartbeat` wrapper updates only `updatedAt`, not `progressAt`.
If the decode takes more than 15 minutes, the watchdog aborts it as stalled even while FFmpeg is working. Track advancing frames during this check, or keep its existing bounded timeout outside the progress watchdog.
**Knowledge Base Used:** [Media-server runtime service](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/media-server-and-web-cluster.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| } else if (Date.now() - lastChangeAt > stallMs) { | ||
| throw new VideoProcessingFailedError( | ||
| `Video processing stopped making progress while ${result.message}`, | ||
| ); |
There was a problem hiding this comment.
Missing updates start duplicate copies
If progress webhooks cannot reach the web server for 20 minutes while FFmpeg keeps working, waitForVideoProcessing now throws VideoProcessingFailedError. Both importLoomVideoWorkflow and processVideoWorkflow catch that error and start another job without checking or cancelling the first one.
The media server gives each request a fresh job ID. Both workers can then write the same output and send competing progress updates. Keep the remote job ID and check or stop that job before restarting work based only on missing webhooks.
Knowledge Base Used: Media-server runtime service
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/workflows/video-processing-status.ts
Line: 119-122
Comment:
**Missing updates start duplicate copies**
If progress webhooks cannot reach the web server for 20 minutes while FFmpeg keeps working, `waitForVideoProcessing` now throws `VideoProcessingFailedError`. Both `importLoomVideoWorkflow` and `processVideoWorkflow` catch that error and start another job without checking or cancelling the first one.
The media server gives each request a fresh job ID. Both workers can then write the same output and send competing progress updates. Keep the remote job ID and check or stop that job before restarting work based only on missing webhooks.
**Knowledge Base Used:** [Media-server runtime service](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/media-server-and-web-cluster.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.…ings Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
hey @greptileai please re-review |
…nutes Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
hey @greptileai please re-review |
…pdates Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
…isible Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
|
hey @greptileai please re-review |
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Rebuilds the Loom CSV importer. Imports now run as durable server-side jobs: every link is checked against Loom in seconds, videos copy a few at a time, and each video's progress shows live on its own job page. Imports keep Loom's title, length and original recording date. A CSV can hold up to 2,000 videos (was 500). Free users can upload and check their whole library, then upgrade to copy it. Transcription and AI for imported videos only run the first time someone opens a video.
The importer in production today processes each row inside a request driven by the browser. It starts every video immediately with no limit, fails a row for good when Loom hiccups, and stops if the tab closes. This PR replaces that path with a queue that 50 people can use at once (see "Many imports at once").
The explanations use the same hand-drawn language as #2312: inked doodles with the boil filter, strokes that draw on, sparks, and the dotted squiggle progress line, plus a five-step "How does importing work?" walkthrough.
Walkthrough
A free user opens Bulk Import, steps through the explainer, drops a Loom export, reviews what was found and has the library checked. The upgrade panel then shows their real library. Private and deleted Looms are flagged before anything is copied.
Returning from checkout starts the import on its own. 24 real Loom videos copy across live, then land in the library with their original dates and play.
A 2,000-video job while it imports, scrolled through to row 1,700, filtered and searched. This run uses the load-test harness described under Testing.
Each part
Getting started. A three-step guide with hand-drawn doodles, a drop zone, and paste-your-links for people without a CSV.
How does importing work? Five animated scenes that auto-advance, with Back, Next and keyboard support.
Review before anything starts. Columns are detected from their contents as well as their headers, so Loom's export, the Cap template, semicolon or tab separated files, and a plain list of links or Loom ids all work. Rows that aren't Loom links and duplicate videos are listed with their spreadsheet row numbers.
Over 2,000 videos. People are told how many CSVs to split into, and that the imports can run side by side.
Free plan. Uploading and checking are free. The upgrade panel shows the person's own library (video count, hours of recordings, owners, real thumbnails) next to the Pro price and a single "Upgrade and import N videos" button. Checkout returns to the job and starts it.
Live progress. The page shows a squiggle progress line, speed, time left, minutes copied, and a hand-drawn pipeline (Checking, Queued, Copying now, In Cap). Each video shows its own stage and progress. The page also offers filters, search, retry for failed videos, stop, and a CSV download that maps every Loom link to its new Cap link.
Done, and back any time. Recent imports on the Loom page lead back to each job.
Original dates. The library is ordered by when each video was recorded in Loom, not when it was imported.
Speed
Measured on the job page with 2,000 videos, using the production build:
Real imports: 37 real Loom videos (29 minutes of footage) were in Cap within 325 s, the first after 10 s. Live polling averaged 2.2 KB per poll. Full numbers are in the attached
loom-import-benchmarks.md.AI only when watched
The media-server webhook no longer queues transcription for imported Loom videos, and stalled-pipeline recovery skips Loom imports that have never been transcribed. The share page and embed start transcription on the first view, as they already do for every video. Verified locally: right after an import, none of the 24 videos had a transcription status. Opening one share page started transcription for that video alone, and the other 23 stayed untouched (see
loom-import-ai-on-view.log).Many imports at once
Imports are built to run side by side without overloading anything. 50 people can each start a 2,000-video CSV at the same moment, and the work is shared out as capacity frees up.
LOOM_IMPORT_GLOBAL_CONCURRENCYvideos (default 12) copy at once across all imports, and no more thanLOOM_IMPORT_CONCURRENCY(default 4) from any one import.Retry-After. Each check step stops starting new Loom requests after 90 seconds, staying well under Vercel's function limit. It gives up only after ten passes in a row without progress, about 33 minutes.Measured with 100,000 rows queued across 50 imports, against a simulated Loom that rate limits and a simulated media server with 9 slots:
The per-pass cost stays flat as imports progress: 7 ms with 1,990 of each import's 2,000 rows done.
EXPLAIN ANALYZEshowed MySQL scanning and sorting 1,999 rows for the next queued row without an index hint. A one-statement "nothing left" check examined 1.5 million rows per 20 passes. Both now hit an index for each row they need. The full numbers and query plans are inloom-import-scale-benchmarks.mdandloom-import-scale-test.log.Stuck versus long. The media server used to fail any job after 60 minutes, and the workflow stopped waiting after 60 minutes, so a 3-hour Loom video that needed re-encoding failed. Neither looks at a clock any more; both watch for progress. A 3-hour recording that keeps moving runs as long as it needs, and a 2-minute recording that freezes is stopped within minutes:
This applies to regular uploads too, since they share the media server job and the workflow wait. Upload links last 24 hours so a long job never outlives them.
Vercel cost. Every media-server webhook, workflow status check and page poll is a Vercel function call. They were cut only where nobody can see the difference:
Measured on a real Loom download (H.264 1080p30), a 3-hour video copies in about 20 s plus moving about 2.4 GB. Videos above 1080p or in other codecs need a re-encode, which ran at 6× real time here (about 30 minutes for 3 hours) and will be slower on shared CPUs.
A third load scenario has 29 people importing 2,000 videos each, where a quarter of 10 people's videos take 30 times longer. All 58,000 links were checked in 9.6 s with none failing, never more than 12 videos were in flight, and every person kept moving. People with long recordings copied 21 to 36 videos in 90 s; everyone else copied 38 to 44.
In the dev environment, a real import of three Loom videos sat waiting because 186 rows from killed test runs held every slot. The cron doesn't run locally, so I applied its silent-import update by hand. The next pass (from a second upload) settled the dead rows and launched the waiting videos, and all four real Loom videos finished in about two minutes with their original titles and dates:
How it works
loom_import_jobsandloom_import_job_items(migration0050). Migration0051adds the dispatch lock table, thedispatched_atcolumn used for fairness, and the(status, job_id)and(job_id, updated_at)indexes.loomImportJobWorkflowchecks every link, then provisions owners and spaces, or stops atawaiting_upgradefor free users. It then hands the job to the shared queue./video/processand/video/importjobs are watched for progress instead of a fixed lifetime. Downloads use an idle timeout reset by each chunk. ffmpeg, including the full decode that checks WebM recordings, runs under an idle timeout reset only when its output timestamp advances. Transfers send a liveness webhook every 5 minutes. ffmpeg progress webhooks are throttled to one a second for regular uploads, matching the share page's 1 s refresh, and one every 5 seconds for bulk imports. Other job types keep their existing lifetimes./video/process/:jobId/status, the same check desktop recordings use) before starting another copy. A check that fails or returns 404 only counts as not seen yet.customCreatedAt. The upload row now recordsraw_file_key, which the existing retry and recovery paths need for Loom imports.GET /api/import/loom/jobsreturns every row on a full load and, after that, only the rows changed since the last poll plus the rows copying now. The page counts rows itself, sends one request at a time so answers apply in order, polls every 2 s while things change and backs off to 10 s while an import waits, stops polling while the tab is in the background, and refreshes fully every 3 minutes. When the job's status changes, waiting rows switch between Ready and Queued without the server resending all 2,000 rows./api/settings/billing/subscribeaccepts an optionalreturnTopath, limited to/dashboard/....@cap/utilsshort-link call in the old importer was a no-op stub and is not carried over.Testing
__tests__/integration/loom-import-jobs.test.tsruns against MySQL whenCAP_LOOM_IMPORT_TEST_DATABASE_URLpoints at a localcap_loom_import_*database. Its 14 tests cover the free plan waiting for an upgrade, settling, retry, cancel, "Already in Cap", the 2,000 row limit, the hourly limit, polling updates, a video retried from its own page after the job ended, overlapping recovery runs, fair sharing of a system-wide limit between people, pausing while the media server is busy, link checks through Loom rate limits, re-queueing a workflow that couldn't start, freeing the slot of a silent import, a stalled upload restarted only once, and a retry from the video page showing as copying. Writing it caught anON DUPLICATE KEY UPDATEcolumn-order bug before it shipped.__tests__/integration/loom-import-scale.test.tsalso needsCAP_LOOM_IMPORT_SCALE_TEST=1. It runs the 50-person, 100,000-row scenarios and the 29-person long-recording scenario described under "Many imports at once". Its first runs caught two deadlocks between Stop and dispatch, one per locking approach, and an unfair tie-break, all fixed.loom-import-greptile-browser-check.log).loom-import-job-status-check.log). A few other media server tests fail on this VM with and without this change: two sparse VP8/VP9 frame-count checks, the damaged-WebM checks (this VM's ffmpeg 6.1.1 exits 0 on decoder errors), and some type errors in older test files. CI doesn't run them; it builds the media server image and checks its health.tsc -b, the database and env package typechecks, and Biome are clean. Biome warns about the two<img>tags; they're kept on purpose so 2,000 Loom thumbnails don't go through the image optimizer.Notes
next startdoesn't compress API responses locally; Vercel does in production.editor_preparing::audiotests named their temp folders from the process ID and the clock, and on macOS two tests running in parallel could pick the same name and fail with "File exists". Each test now gets its own folder.LOOM_IMPORT_GLOBAL_CONCURRENCYshould roughly match the media server's bulk capacity on Railway: replicas × (processes per replica − 1). Above that, the busy pause keeps extra videos waiting instead of piling onto the server.LOOM_IMPORT_GLOBAL_CONCURRENCYshorten it proportionally.LOOM_IMPORT_GLOBAL_CONCURRENCYallows at once.The PR appears safe to merge, with all previous findings addressed and no new blocking issues found.
What we checked:
sendWebhookshares one running promise. Further calls mark another update pending, and the loop reads the latest job state before sending it.Summary
Replaces browser-driven Loom CSV imports with durable jobs, a shared queue, and live progress for up to 2,000 videos per CSV.
Reviews (10) · Last reviewed commit: "fix: keep the import page refreshing as ..." · Reviewed by Greptile