Skip to content

feat: guided Loom bulk import with live progress, 2,000 videos per CSV - #2428

Draft
richiemcilroy wants to merge 39 commits into
mainfrom
cursor/loom-bulk-importer-7c23
Draft

richiemcilroy wants to merge 39 commits into
mainfrom
cursor/loom-bulk-importer-7c23

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

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

loom-import-1-guide-review-free-plan.mp4

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.

loom-import-2-upgrade-live-import-library.mp4

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.

loom-import-3-live-2000-videos.mp4

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.

01-bulk-import-guide.png

How does importing work? Five animated scenes that auto-advance, with Back, Next and keyboard support.

02-how-it-works-step-1.png 02-how-it-works-step-2.png
02-how-it-works-step-3.png 02-how-it-works-step-4.png
02-how-it-works-step-5.png

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.

05-review.png

03-paste-links.png 03b-pasted-links-review.png

Over 2,000 videos. People are told how many CSVs to split into, and that the imports can run side by side.

04-over-limit.png

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.

06-upgrade-panel.png
06-upgrade-panel-dark.png
07-flagged-before-copying.png

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.

09-importing-2000-videos.png
09-importing-2000-videos-dark.png

Done, and back any time. Recent imports on the Loom page lead back to each job.

10-done.png
08-recent-imports.png

Original dates. The library is ordered by when each video was recorded in Loom, not when it was imported.

11-library-original-dates.png

Speed

Queueing 2,000 rows Before After
Who drives it The browser, 10 rows per request, 1.5 s pause between batches, tab must stay open A server workflow; the tab can close
Loom calls Up to 6 per row, 338 ms per row measured on 120 real videos One aliased GraphQL request per 25 videos
Time to queue 2,000 About 16 minutes before any database or provisioning work All links checked in 3.3 s, owners and spaces ready in 3.7 s, first videos copying at about 4 s

Measured on the job page with 2,000 videos, using the production build:

Result
Server-rendered HTML 128 KB (1,054 KB before only the first screen of rows was rendered on the server)
Live updates and scrolling all rows 60 fps, worst frame 16.8 ms, no long tasks
Fast scrolling at 4x CPU throttle p50 16.7 ms, mean 23 ms per frame
Filter / search over 2,000 rows about 60 ms / 32 ms
JS heap / rows in the DOM 25 MB / 25

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.

  • One queue for everyone. The queue lives in MySQL, so nothing depends on a Vercel function remembering state between requests. A single lock row coordinates dispatch. No more than LOOM_IMPORT_GLOBAL_CONCURRENCY videos (default 12) copy at once across all imports, and no more than LOOM_IMPORT_CONCURRENCY (default 4) from any one import.
  • Fair. Free slots go to whichever person has the fewest videos copying, then to the import that has waited longest. Someone who uploads three CSVs doesn't get three times the share.
  • Respects the media server. The Railway media server already turns away bulk work while interactive uploads need the room. As soon as any import is told the server is busy, or Loom is rate limiting, nothing new starts until it gets in. Each pass starts at most 4 videos, so capacity is approached gradually.
  • Event driven, no extra invocations. A pass runs when a video finishes or fails, when the media server accepts a video, when a job is ready, and from the recovery cron. It runs inside requests that already happen: the media-server webhook, workflow steps, and the cron.
  • Survives failures. If a workflow can't start, its video is queued once more before it fails. If Loom rate limits a download link, that video waits instead of failing. Recovery restarts an upload that stalled before reaching the media server once, then fails videos that have gone 45 minutes without any update, up to 500 per run in one statement. Recovery's own writes can't keep a dead run alive, so it never holds a slot. A pass that hits a lock error is retried.
  • Link checks wait out Loom. Rate-limited rows stay pending and are re-checked with backoff, honouring 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:

Result
Check all 100,000 links while 69% of Loom requests were rate limited 17.5 s, 0 rows failed
Videos copying at once (limit 12, media server capacity 9) never above 12; media server never above 9
Videos copied per import in the same 90 s 41 to 56, every import progressed
50 imports of 40 videos run to the end all 50 completed; only the simulated failures failed
Pass when every slot is full 7 queries, 6 ms
A video finished (settle it, launch the next) 18 queries, 12 ms; a second report of it costs 1 query
Polling update 5 queries, 2 ms, only changed rows

The per-pass cost stays flat as imports progress: 7 ms with 1,990 of each import's 2,000 rows done. EXPLAIN ANALYZE showed 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 in loom-import-scale-benchmarks.md and loom-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:

Where it can get stuck How it is noticed Gives up after
Downloading from Loom No bytes received 3 min without data
Checking a WebM recording Decoded frames advancing 5 min without a new frame
Encoding ffmpeg's output timestamp stops advancing (its status lines alone don't count) 5 min without a new frame
Uploading to storage Each attempt has a deadline that grows with file size, assuming at least 1 MB/s. The minimum is 10 minutes, and a 3 GB file gets about 51 minutes Retried up to 4 times
Anything else on the media server Watchdog on the job's phase, progress, bytes and heartbeats 15 min without progress
Media server lost the job (restart, crash) The video's status stops changing for 20 min, so the workflow asks the media server about that job If the media server still has the job, the workflow keeps waiting, refreshes the video's heartbeat and checks again on each poll, so no second copy starts. If it finished, its final state is replayed. A failed check or a 404, which can come from another replica, is not proof. The video starts again (up to 2 times, from the saved original when one exists) only after checks every 30 s have not seen the job for 10 minutes
Workflow itself stopped Recovery sees no update on the video 45 min, then frees the slot and marks the video failed so it can be retried

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:

Before After
Progress webhooks while encoding Every ffmpeg status line (about every 0.5 s) Regular uploads at most once a second, which is how often the share page refreshes, so nothing looks different. Bulk Loom imports at most every 5 s. Plus a liveness ping every 5 min during long transfers
The same two re-encoded Loom videos, real import 238 and 239 "processing" webhooks 34 and 51
Import workflow status checks per video Every 30 s Backs off to every 2 min. The import slot frees from the webhook, not the check, so this does not slow the queue
Import page, visible tab Every 2 s while anything changes, backing off to 10 s Unchanged
Import page, background tab Every 15 s None. It catches up when the tab is shown again

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:

loom-import-queue-resume-e2e.mp4

How it works

  • Tables: loom_import_jobs and loom_import_job_items (migration 0050). Migration 0051 adds the dispatch lock table, the dispatched_at column used for fairness, and the (status, job_id) and (job_id, updated_at) indexes.
  • Job workflow: loomImportJobWorkflow checks every link, then provisions owners and spaces, or stops at awaiting_upgrade for free users. It then hands the job to the shared queue.
  • Dispatch: each pass takes the lock row, locks active import rows by primary key in a fixed order (the same order Stop uses, so the two can't deadlock), settles finished videos from their real upload state, and launches into free slots. A video retried from its own page shows as copying in its import while the retry runs, and is marked imported when it finishes, even after the job has ended or been stopped. "Retry failed" also tidies up any row like that.
  • Media server: /video/process and /video/import jobs 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.
  • Quiet jobs: both workflows keep the media server's job id. When a video's status stops changing they check that job (/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.
  • Recovery: the cron restarts stalled checks and stuck starts, fails imports that went silent, and runs a dispatch pass. Each restart first claims the row with an update conditioned on the status and timestamp it saw, so overlapping cron runs restart a video only once.
  • Imported videos: each one keeps its Loom title, duration, size and customCreatedAt. The upload row now records raw_file_key, which the existing retry and recovery paths need for Loom imports.
  • Who can import what: everyone can import into their own library; owners and spaces from the CSV need an org admin.
  • Limits: each person can start up to 30 imports per organization per hour, since every import checks its links with Loom from our servers.
  • Polling: GET /api/import/loom/jobs returns 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.
  • Creating an import: the browser sends canonical Loom ids with owners and spaces listed once, so 2,000 rows stay around 100 KB, far below the 1 MB limit on server action requests.
  • Checkout: /api/settings/billing/subscribe accepts an optional returnTo path, limited to /dashboard/....
  • Workflow runtime: the job workflow is covered by the workflow-runtime boundary test and checks Pro through a shared workflow-safe helper. The @cap/utils short-link call in the old importer was a no-op stub and is not carried over.

Testing

  • Unit tests: apps/web, 244 files and 3,472 tests passing. New tests cover CSV parsing and column detection (including files of bare Loom ids), Loom lookups and retry delays, fair scheduling, the compact create request, status derivation and settling, the results CSV, the client merge and local counts, request ordering on the job page, the virtual list after searches, uploading a CSV in the importer, a rate-limited Loom download link, the webhook and recovery deferral, waiting out a 4-hour recording that keeps reporting in, giving up on one whose status stops changing, restarting a stalled Loom video from its saved original, not starting a second copy while the media server still runs the job or while its status checks fail for a few minutes, and pausing polling in a background tab.
  • Database integration test: __tests__/integration/loom-import-jobs.test.ts runs against MySQL when CAP_LOOM_IMPORT_TEST_DATABASE_URL points at a local cap_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 an ON DUPLICATE KEY UPDATE column-order bug before it shipped.
  • Load test: __tests__/integration/loom-import-scale.test.ts also needs CAP_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.
  • Real imports: run end to end in Chrome against the media server with real public Loom videos. The 2,000-video page measurements use a harness that seeds a 2,000-row job from real Loom titles and thumbnails, then drives realistic churn through the same tables.
  • Job page sync in Chrome: a 2,000-row job moved from checking to importing without any row changing, and the visible rows went from Ready to Queued. Searching with no matches from row 1,001 and then clearing the search brought back row 1. A CSV of three bare Loom ids offered "Import 3 videos" (see loom-import-greptile-browser-check.log).

loom-import-greptile-fixes.mp4

  • Media server: the job manager (24), idle timeout (2), video route (35), progress webhook (2) and storage upload (18) tests pass. The progress webhook tests send 40 encoding updates in 2.4 s: a regular upload passes 2 to 4 of them on, a bulk import 1. Real-ffmpeg tests show an encode running at least twice its idle limit while frames advance, and a stalled input being stopped, for both encoding and the WebM check. Against the running media server, with its webhooks pointed at a dead port, the job check reported the job as active for its 12 s run and then replayed its final "complete" state with metadata. An unknown job came back as not found, which the workflow only counts as not seen yet (see 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.
  • Checks: 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

  • Loom doesn't document its export's column names, which is why detection relies on cell contents.
  • Stripe checkout wasn't exercised locally. The return from checkout was simulated by setting the subscription status on the test user.
  • next start doesn't compress API responses locally; Vercel does in production.
  • On a small machine the media server throttles under load, and a video that hits capacity waits at least 30 s under the existing shared retry policy. I left that policy unchanged.
  • Also fixes a flaky desktop test that failed this PR's macOS build. The editor_preparing::audio tests 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_CONCURRENCY should 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.
  • Recovery runs on the existing cron (every 15 minutes), so a row whose workflow died is freed within about an hour. A row whose media server job died is restarted by its own workflow after about 30 minutes: 20 without any change, then 10 in which the media server never reports the job.
  • How long a big batch takes depends on the media server. Each video holds a slot for about 35–60 s on average in local runs, so 58,000 videos at 12 slots take roughly 2 to 3 days. With a single media server replica, which by default accepts at most 3 bulk jobs at a time, it would be over a week. More replicas plus a higher LOOM_IMPORT_GLOBAL_CONCURRENCY shorten it proportionally.
  • A 3-hour video needs about 5 GB of temporary disk on the media server, for the download plus the processed copy. Make sure Railway's disk has room for as many of those as LOOM_IMPORT_GLOBAL_CONCURRENCY allows at once.
Open in Web Open in Cursor 

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with all previous findings addressed and no new blocking issues found.

What we checked:

  • Faster webhooks stay ordered: sendWebhook shares one running promise. Further calls mark another update pending, and the loop reads the latest job state before sending it.
  • Faster polls stay ordered: Every snapshot request waits for the previous request. The next polling timer starts only after its request settles.

Summary

Replaces browser-driven Loom CSV imports with durable jobs, a shared queue, and live progress for up to 2,000 videos per CSV.

  • The latest changes restore two-second page polling and use separate webhook intervals for regular uploads and bulk imports.
  • All eleven previous findings are addressed in the current code.
  • No new actionable issues were found.

Reviews (10) · Last reviewed commit: "fix: keep the import page refreshing as ..." · Reviewed by Greptile

cursoragent and others added 4 commits October 7, 2026 03:55
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>
cursoragent and others added 2 commits October 7, 2026 05:14
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>
cursoragent and others added 2 commits October 7, 2026 05:55
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>
cursoragent and others added 2 commits October 7, 2026 06:12
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>
@cursor cursor Bot changed the title feat: world-class Loom CSV importer feat: guided Loom bulk import with live progress, 2,000 videos per CSV Oct 7, 2026
cursoragent and others added 2 commits October 7, 2026 06:32
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>
@cursor

cursor Bot commented Oct 7, 2026

Copy link
Copy Markdown

@greptileai please review

Comment thread apps/web/lib/loom-import/recovery.ts Outdated
Comment on lines +82 to +90
await db()
.update(videoUploads)
.set({
processingMessage: "Retrying Loom import...",
rawFileKey,
updatedAt: now,
})
.where(eq(videoUploads.videoId, row.videoId));
await start(importLoomVideoWorkflow, [

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

Comment on lines +471 to +474
and(
eq(loomImportJobItems.videoId, Video.VideoId.make(videoId)),
eq(loomImportJobItems.status, "importing"),
),

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

Comment on lines +84 to +89
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);

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

Comment on lines +74 to +82
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]);

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

Comment thread apps/web/lib/loom-import/snapshot.ts Outdated
Comment on lines +154 to +158
items: full
? all
: all.filter(
(item) => item.v > (since as number) - LOOM_IMPORT_CURSOR_OVERLAP_MS,
),

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

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.

Comment on lines +198 to +200
const headerless = first.some(
(cell) => /loom\.com/i.test(cell) && extractLoomVideoId(cell) !== null,
);

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

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.

cursoragent and others added 3 commits October 7, 2026 07:23
…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>
@cursor

cursor Bot commented Oct 7, 2026

Copy link
Copy Markdown

@greptileai please review

@cursor

cursor Bot commented Oct 8, 2026

Copy link
Copy Markdown

hey @greptileai please re-review

cursoragent and others added 4 commits October 8, 2026 04:26
… 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>
@cursor

cursor Bot commented Oct 8, 2026

Copy link
Copy Markdown

hey @greptileai please re-review

Comment thread apps/media-server/src/routes/video.ts Outdated
);
repairedTempFile = repairedFile;
updateJob(jobId, { metadata });
extendJobLifetimeForMedia(jobId, metadata.duration);

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

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.

cursoragent and others added 3 commits October 8, 2026 04:56
…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>
@cursor

cursor Bot commented Oct 8, 2026

Copy link
Copy Markdown

hey @greptileai please re-review

Comment on lines +607 to +613
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();

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

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.

Comment on lines +119 to +122
} else if (Date.now() - lastChangeAt > stallMs) {
throw new VideoProcessingFailedError(
`Video processing stopped making progress while ${result.message}`,
);

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

cursoragent and others added 2 commits October 8, 2026 05:27
…ings

Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
@cursor

cursor Bot commented Oct 8, 2026

Copy link
Copy Markdown

hey @greptileai please re-review

…nutes

Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
@cursor

cursor Bot commented Oct 8, 2026

Copy link
Copy Markdown

hey @greptileai please re-review

cursoragent and others added 2 commits October 8, 2026 07:33
…pdates

Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
…isible

Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
@cursor

cursor Bot commented Oct 8, 2026

Copy link
Copy Markdown

hey @greptileai please re-review

Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>

This branch had an error being deployed

1 failed deployment
Preview — a986bf22 Deployed Oct 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants