From 560dc23ffc96dd11cc6fd62094745b974488f80a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:31:30 -0500 Subject: [PATCH 01/14] fix(web): rate limit transcription and AI generation retries --- apps/web/app/api/v1/[...route]/route.ts | 15 +++++++++++++++ .../app/api/videos/[videoId]/retry-ai/route.ts | 11 +++++++++++ .../videos/[videoId]/retry-transcription/route.ts | 11 +++++++++++ apps/web/lib/processing-retry-limit.ts | 11 +++++++++++ apps/web/lib/rate-limit.ts | 3 +++ 5 files changed, 51 insertions(+) create mode 100644 apps/web/lib/processing-retry-limit.ts diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 66eb35ab59a..4c760143606 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -179,6 +179,7 @@ import { getEffectiveOrganizationRole, } from "@/lib/permissions/roles"; import { normalizePlaybackSpeed } from "@/lib/playback-speed"; +import { processingRetryLimited } from "@/lib/processing-retry-limit"; import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { blockedS3EndpointMessage, @@ -7852,6 +7853,20 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( if (row.ownerId !== principal.id) { return yield* forbidden(requestId); } + if (payload.retry === true) { + const request = yield* HttpServerRequest.HttpServerRequest; + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + headers.set(name, value); + } + if ( + yield* Effect.promise(() => + processingRetryLimited(principal.id, headers), + ) + ) { + return yield* rateLimited(requestId); + } + } const includesTranscript = payload.target !== "ai"; const includesAi = payload.target !== "transcript"; if (includesTranscript && rules.settings.disableTranscript) { diff --git a/apps/web/app/api/videos/[videoId]/retry-ai/route.ts b/apps/web/app/api/videos/[videoId]/retry-ai/route.ts index eb26330ae7a..6c796c330a5 100644 --- a/apps/web/app/api/videos/[videoId]/retry-ai/route.ts +++ b/apps/web/app/api/videos/[videoId]/retry-ai/route.ts @@ -7,6 +7,10 @@ import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { startAiGeneration } from "@/lib/generate-ai"; +import { + PROCESSING_RETRY_RATE_MESSAGE, + processingRetryLimited, +} from "@/lib/processing-retry-limit"; import { isAiGenerationEnabled } from "@/utils/flags"; const LEGACY_AI_SUMMARY_FALLBACK = @@ -97,6 +101,13 @@ export async function POST( ); } + if (await processingRetryLimited(user.id, request.headers)) { + return Response.json( + { error: PROCESSING_RETRY_RATE_MESSAGE }, + { status: 429 }, + ); + } + const result = await startAiGeneration(videoId, video.ownerId); if (!result.success) { diff --git a/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts b/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts index 00b03f36c0b..3bc331eb178 100644 --- a/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts +++ b/apps/web/app/api/videos/[videoId]/retry-transcription/route.ts @@ -5,6 +5,10 @@ import { videos } from "@cap/database/schema"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + PROCESSING_RETRY_RATE_MESSAGE, + processingRetryLimited, +} from "@/lib/processing-retry-limit"; export async function POST( request: Request, @@ -39,6 +43,13 @@ export async function POST( return Response.json({ error: "Unauthorized" }, { status: 403 }); } + if (await processingRetryLimited(user.id, request.headers)) { + return Response.json( + { error: PROCESSING_RETRY_RATE_MESSAGE }, + { status: 429 }, + ); + } + // Reset status to null - this will trigger automatic retry via get-status.ts await db() .update(videos) diff --git a/apps/web/lib/processing-retry-limit.ts b/apps/web/lib/processing-retry-limit.ts new file mode 100644 index 00000000000..68f8eac99eb --- /dev/null +++ b/apps/web/lib/processing-retry-limit.ts @@ -0,0 +1,11 @@ +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; + +export const PROCESSING_RETRY_RATE_MESSAGE = + "Too many processing retries. Try again in an hour."; + +export function processingRetryLimited(userId: string, headers?: Headers) { + return isRateLimited(RATE_LIMIT_IDS.PROCESSING_RETRY, { + key: `processing-retry:${userId}`, + ...(headers ? { headers } : {}), + }); +} diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts index 2b46bf8327b..3528532acfd 100644 --- a/apps/web/lib/rate-limit.ts +++ b/apps/web/lib/rate-limit.ts @@ -207,6 +207,8 @@ export const RATE_LIMIT_IDS = { COLLECTION_PASSWORD_PER_VIEWER: "rl_collection_password_per_viewer", /** Comments, replies and reactions per author. Suggested: 60 / 1m per key (user). */ COMMENT_CREATE: "rl_comment_create", + /** Transcription and AI generation retries per user, from the share page and the agent API. Suggested: 30 / 1h per key (user). */ + PROCESSING_RETRY: "rl_processing_retry", /** Video edit saves and restores per user: each one re-encodes the video and regenerates AI output. Suggested: 30 / 1h per key (user). */ VIDEO_EDIT: "rl_video_edit", /** Share-page downloads by viewers who are not members. Suggested: 30 / 1m per IP. */ @@ -269,6 +271,7 @@ const FALLBACK_RATE_LIMITS: Record = { limit: 1, windowMs: 60 * MINUTE_MS, }, + [RATE_LIMIT_IDS.PROCESSING_RETRY]: { limit: 30, windowMs: 60 * MINUTE_MS }, [RATE_LIMIT_IDS.VIDEO_EDIT]: { limit: 30, windowMs: 60 * MINUTE_MS }, [RATE_LIMIT_IDS.VIEWER_DOWNLOAD]: { limit: 30, windowMs: MINUTE_MS }, [RATE_LIMIT_IDS.MOBILE_EVENTS]: { limit: 30, windowMs: MINUTE_MS }, From a3eac7b3f5a50e81c3f1c033499307fd35789310 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:31:30 -0500 Subject: [PATCH 02/14] test(web): cover the processing retry limit --- .../unit/processing-retry-limit.test.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/web/__tests__/unit/processing-retry-limit.test.ts diff --git a/apps/web/__tests__/unit/processing-retry-limit.test.ts b/apps/web/__tests__/unit/processing-retry-limit.test.ts new file mode 100644 index 00000000000..37ae6413222 --- /dev/null +++ b/apps/web/__tests__/unit/processing-retry-limit.test.ts @@ -0,0 +1,162 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + user: vi.fn(), + video: vi.fn(), + update: vi.fn(), + startAiGeneration: vi.fn(), + aiEnabled: vi.fn(), +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ limit: async () => mocks.video() }), + }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => mocks.update(values), + }), + }), + }), +})); +vi.mock("@cap/database/auth/request-site", () => ({ + isCrossSiteCookieWrite: () => false, +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: mocks.user, +})); +vi.mock("@cap/database/schema", () => ({ users: {}, videos: {} })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/generate-ai", () => ({ + startAiGeneration: mocks.startAiGeneration, +})); +vi.mock("@/utils/flags", () => ({ isAiGenerationEnabled: mocks.aiEnabled })); + +import { POST as retryAi } from "@/app/api/videos/[videoId]/retry-ai/route"; +import { POST as retryTranscription } from "@/app/api/videos/[videoId]/retry-transcription/route"; +import { + PROCESSING_RETRY_RATE_MESSAGE, + processingRetryLimited, +} from "@/lib/processing-retry-limit"; + +const RETRIES_PER_HOUR = 30; + +let userCounter = 0; +let userId = ""; + +const request = () => + new Request("http://localhost/api/videos/video-1/retry", { method: "POST" }); +const params = { params: Promise.resolve({ videoId: "video-1" }) } as never; + +beforeEach(() => { + vi.clearAllMocks(); + userCounter++; + userId = `owner-${userCounter}`; + mocks.user.mockResolvedValue({ id: userId }); + mocks.video.mockImplementation(() => [ + { + id: "video-1", + ownerId: userId, + transcriptionStatus: "COMPLETE", + metadata: { aiGenerationStatus: "ERROR" }, + email: "owner@example.com", + }, + ]); + mocks.aiEnabled.mockResolvedValue(true); + mocks.startAiGeneration.mockResolvedValue({ success: true, message: "ok" }); +}); + +describe("processing retry limit", () => { + it("allows normal retries and refuses a loop of them", async () => { + for (let attempt = 0; attempt < RETRIES_PER_HOUR; attempt++) { + await expect(processingRetryLimited(userId)).resolves.toBe(false); + } + await expect(processingRetryLimited(userId)).resolves.toBe(true); + await expect(processingRetryLimited(`${userId}-other`)).resolves.toBe( + false, + ); + }); + + it("retries AI generation for the owner", async () => { + const response = await retryAi(request(), params); + + expect(response.status).toBe(200); + expect(mocks.startAiGeneration).toHaveBeenCalledOnce(); + }); + + it("stops AI generation retries past the limit with a clear error", async () => { + for (let attempt = 0; attempt < RETRIES_PER_HOUR; attempt++) { + expect((await retryAi(request(), params)).status).toBe(200); + } + + const response = await retryAi(request(), params); + + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ + error: PROCESSING_RETRY_RATE_MESSAGE, + }); + expect(mocks.startAiGeneration).toHaveBeenCalledTimes(RETRIES_PER_HOUR); + }); + + it("retries transcription for the owner", async () => { + const response = await retryTranscription(request(), params); + + expect(response.status).toBe(200); + expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ + transcriptionStatus: null, + }); + }); + + it("stops transcription retries past the limit without resetting the status", async () => { + for (let attempt = 0; attempt < RETRIES_PER_HOUR; attempt++) { + expect((await retryTranscription(request(), params)).status).toBe(200); + } + + const response = await retryTranscription(request(), params); + + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ + error: PROCESSING_RETRY_RATE_MESSAGE, + }); + expect(mocks.update).toHaveBeenCalledTimes(RETRIES_PER_HOUR); + }); + + it("shares one budget between both share page retries", async () => { + for (let attempt = 0; attempt < RETRIES_PER_HOUR; attempt++) { + await retryTranscription(request(), params); + } + + expect((await retryAi(request(), params)).status).toBe(429); + expect(mocks.startAiGeneration).not.toHaveBeenCalled(); + }); + + it("checks the limit before an agent retry reaches transcription or AI generation", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const start = source.indexOf('.handle("processCap"'); + const handler = source.slice( + start, + source.indexOf('.handle("replaceTranscript"', start), + ); + const limitCheck = handler.indexOf( + "processingRetryLimited(principal.id, headers)", + ); + + expect(limitCheck).toBeGreaterThan( + handler.indexOf("payload.retry === true"), + ); + expect(limitCheck).toBeLessThan(handler.indexOf("runAgentMutation(")); + expect(limitCheck).toBeLessThan(handler.indexOf("transcribeVideo(")); + expect(limitCheck).toBeLessThan(handler.indexOf("startAiGeneration(")); + expect(handler.slice(limitCheck)).toMatch( + /^processingRetryLimited\(principal\.id, headers\),\s*\)\s*\)\s*\{\s*return yield\* rateLimited\(requestId\);/, + ); + }); +}); From a9709e379b9ae9d4d41e73783fb8125a640b3dd8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:41:08 -0500 Subject: [PATCH 03/14] feat(web): tell when AI generation would run again for a video --- apps/web/lib/generate-ai.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index d7d5aeaebb1..989029a82dd 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -15,6 +15,17 @@ type GenerateAiResult = { const LEGACY_AI_SUMMARY_FALLBACK = "The AI was unable to generate a proper summary for this content."; +export function isAiGenerationRerun(metadata: VideoMetadata) { + const status = metadata.aiGenerationStatus; + if (!status || status === "PROCESSING" || status === "QUEUED") return false; + return !( + status === "COMPLETE" && + metadata.summary && + metadata.summary !== LEGACY_AI_SUMMARY_FALLBACK && + metadata.chapters + ); +} + const getAffectedRows = (result: unknown) => { if (Array.isArray(result)) { return ( From 816f1614e0a46a6df5ea7daac9ec247338b6d73b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:41:09 -0500 Subject: [PATCH 04/14] fix(web): limit agent processing reruns from the video's current state --- apps/web/app/api/v1/[...route]/route.ts | 54 ++++++++++++++++--------- apps/web/lib/processing-retry-limit.ts | 2 + 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 4c760143606..ec7f0de94fc 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -160,7 +160,7 @@ import { completeDeveloperVideoUpload, createDeveloperVideoUpload, } from "@/lib/developer-video-upload"; -import { startAiGeneration } from "@/lib/generate-ai"; +import { isAiGenerationRerun, startAiGeneration } from "@/lib/generate-ai"; import { canUseInfra } from "@/lib/infra-access"; import { autoTopUpSettingsError } from "@/lib/infra-auto-top-up"; import { @@ -179,7 +179,10 @@ import { getEffectiveOrganizationRole, } from "@/lib/permissions/roles"; import { normalizePlaybackSpeed } from "@/lib/playback-speed"; -import { processingRetryLimited } from "@/lib/processing-retry-limit"; +import { + PROCESSING_RETRY_WINDOW_MS, + processingRetryLimited, +} from "@/lib/processing-retry-limit"; import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { blockedS3EndpointMessage, @@ -427,12 +430,12 @@ const temporarilyUnavailable = ( requestId, }); -const rateLimited = (requestId: string) => +const rateLimited = (requestId: string, retryAfterMs = 60_000) => new Agent.AgentRateLimitedError({ message: "Too many requests. Try again later", code: "RATE_LIMITED", retryable: true, - retryAfterMs: 60_000, + retryAfterMs, requestId, }); @@ -7853,20 +7856,6 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( if (row.ownerId !== principal.id) { return yield* forbidden(requestId); } - if (payload.retry === true) { - const request = yield* HttpServerRequest.HttpServerRequest; - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - headers.set(name, value); - } - if ( - yield* Effect.promise(() => - processingRetryLimited(principal.id, headers), - ) - ) { - return yield* rateLimited(requestId); - } - } const includesTranscript = payload.target !== "ai"; const includesAi = payload.target !== "transcript"; if (includesTranscript && rules.settings.disableTranscript) { @@ -7897,6 +7886,35 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( ); } } + // Decided from the video's current state rather than the request, + // so an idempotent replay into a failed state is still a rerun and + // a replay of a rerun that was already accepted is not charged again. + const transcriptionRerun = + includesTranscript && + payload.retry === true && + row.transcriptionStatus === "ERROR"; + const aiRerun = + includesAi && + (payload.target === "ai" || + row.transcriptionStatus === "COMPLETE") && + isAiGenerationRerun(normalizeAgentMetadata(row.metadata)); + if (transcriptionRerun || aiRerun) { + const request = yield* HttpServerRequest.HttpServerRequest; + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + headers.set(name, value); + } + if ( + yield* Effect.promise(() => + processingRetryLimited(principal.id, headers), + ) + ) { + return yield* rateLimited( + requestId, + PROCESSING_RETRY_WINDOW_MS, + ); + } + } const state = yield* runAgentMutation({ principal, operation: "process_cap", diff --git a/apps/web/lib/processing-retry-limit.ts b/apps/web/lib/processing-retry-limit.ts index 68f8eac99eb..7b2a40122a3 100644 --- a/apps/web/lib/processing-retry-limit.ts +++ b/apps/web/lib/processing-retry-limit.ts @@ -1,5 +1,7 @@ import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +export const PROCESSING_RETRY_WINDOW_MS = 60 * 60 * 1000; + export const PROCESSING_RETRY_RATE_MESSAGE = "Too many processing retries. Try again in an hour."; From e0fc0de6bb924291927f3874643ca0039fccbbcd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:41:09 -0500 Subject: [PATCH 05/14] test(web): cover agent processing reruns and replays --- .../unit/ai-generation-rerun.test.ts | 52 +++++++++++++++++++ .../unit/processing-retry-limit.test.ts | 15 ++++-- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 apps/web/__tests__/unit/ai-generation-rerun.test.ts diff --git a/apps/web/__tests__/unit/ai-generation-rerun.test.ts b/apps/web/__tests__/unit/ai-generation-rerun.test.ts new file mode 100644 index 00000000000..295e666713f --- /dev/null +++ b/apps/web/__tests__/unit/ai-generation-rerun.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/database", () => ({ db: vi.fn() })); +vi.mock("workflow/api", () => ({ start: vi.fn() })); +vi.mock("@/lib/ai/provider", () => ({ isAiConfigured: () => true })); +vi.mock("@/workflows/generate-ai", () => ({ generateAiWorkflow: vi.fn() })); + +import { isAiGenerationRerun } from "@/lib/generate-ai"; + +const legacySummary = + "The AI was unable to generate a proper summary for this content."; + +describe("AI generation rerun", () => { + it("treats a video that never ran AI generation as a first run", () => { + expect(isAiGenerationRerun({})).toBe(false); + }); + + it("does not count work that is already queued or running", () => { + expect(isAiGenerationRerun({ aiGenerationStatus: "QUEUED" })).toBe(false); + expect(isAiGenerationRerun({ aiGenerationStatus: "PROCESSING" })).toBe( + false, + ); + }); + + it("does not count a finished result, which generation leaves alone", () => { + expect( + isAiGenerationRerun({ + aiGenerationStatus: "COMPLETE", + summary: "A summary", + chapters: [{ title: "Intro", start: 0 }], + }), + ).toBe(false); + }); + + it("counts every state that starts generation again", () => { + expect(isAiGenerationRerun({ aiGenerationStatus: "ERROR" })).toBe(true); + expect(isAiGenerationRerun({ aiGenerationStatus: "SKIPPED" })).toBe(true); + expect( + isAiGenerationRerun({ + aiGenerationStatus: "COMPLETE", + summary: legacySummary, + chapters: [{ title: "Intro", start: 0 }], + }), + ).toBe(true); + expect( + isAiGenerationRerun({ + aiGenerationStatus: "COMPLETE", + summary: "A summary", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/processing-retry-limit.test.ts b/apps/web/__tests__/unit/processing-retry-limit.test.ts index 37ae6413222..430174d3c11 100644 --- a/apps/web/__tests__/unit/processing-retry-limit.test.ts +++ b/apps/web/__tests__/unit/processing-retry-limit.test.ts @@ -135,7 +135,7 @@ describe("processing retry limit", () => { expect(mocks.startAiGeneration).not.toHaveBeenCalled(); }); - it("checks the limit before an agent retry reaches transcription or AI generation", () => { + it("decides an agent rerun from the video's current state before any work starts", () => { const source = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), "utf8", @@ -149,14 +149,23 @@ describe("processing retry limit", () => { "processingRetryLimited(principal.id, headers)", ); + expect(handler).toMatch( + /const transcriptionRerun =\s*includesTranscript &&\s*payload\.retry === true &&\s*row\.transcriptionStatus === "ERROR";/, + ); + expect(handler).toMatch( + /const aiRerun =\s*includesAi &&[\s\S]*?isAiGenerationRerun\(normalizeAgentMetadata\(row\.metadata\)\);/, + ); + expect(limitCheck).toBeGreaterThan( + handler.indexOf("if (transcriptionRerun || aiRerun)"), + ); expect(limitCheck).toBeGreaterThan( - handler.indexOf("payload.retry === true"), + handler.indexOf("isAiGenerationEnabled("), ); expect(limitCheck).toBeLessThan(handler.indexOf("runAgentMutation(")); expect(limitCheck).toBeLessThan(handler.indexOf("transcribeVideo(")); expect(limitCheck).toBeLessThan(handler.indexOf("startAiGeneration(")); expect(handler.slice(limitCheck)).toMatch( - /^processingRetryLimited\(principal\.id, headers\),\s*\)\s*\)\s*\{\s*return yield\* rateLimited\(requestId\);/, + /^processingRetryLimited\(principal\.id, headers\),\s*\)\s*\)\s*\{\s*return yield\* rateLimited\(\s*requestId,\s*PROCESSING_RETRY_WINDOW_MS,\s*\);/, ); }); }); From 2da07882582531d4c7c38f6f6423653ae445194b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:45:08 -0500 Subject: [PATCH 06/14] fix(web): charge agent AI reruns only when generation starts --- apps/web/app/api/v1/[...route]/route.ts | 28 +++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index ec7f0de94fc..8e5600f74c3 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -7886,19 +7886,11 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( ); } } - // Decided from the video's current state rather than the request, - // so an idempotent replay into a failed state is still a rerun and - // a replay of a rerun that was already accepted is not charged again. - const transcriptionRerun = - includesTranscript && - payload.retry === true && - row.transcriptionStatus === "ERROR"; - const aiRerun = - includesAi && - (payload.target === "ai" || - row.transcriptionStatus === "COMPLETE") && - isAiGenerationRerun(normalizeAgentMetadata(row.metadata)); - if (transcriptionRerun || aiRerun) { + // Reruns are decided from the video's current state rather than the + // request alone: an idempotent replay into a failed AI state still + // starts generation again, while a replay of a rerun that was + // already accepted finds the work queued and is not charged again. + const limitRerun = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { @@ -7914,6 +7906,13 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( PROCESSING_RETRY_WINDOW_MS, ); } + }); + if ( + includesTranscript && + payload.retry === true && + row.transcriptionStatus === "ERROR" + ) { + yield* limitRerun; } const state = yield* runAgentMutation({ principal, @@ -8014,6 +8013,9 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( return yield* temporarilyUnavailable(requestId, result.message); } } else if (state.target !== "transcript") { + if (isAiGenerationRerun(normalizeAgentMetadata(row.metadata))) { + yield* limitRerun; + } const result = yield* Effect.tryPromise(() => startAiGeneration(state.id, state.ownerId), ); From f7e1d5334e68f2b04c12bdaaac423251f8749652 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:45:08 -0500 Subject: [PATCH 07/14] test(web): cover where agent reruns are charged --- .../unit/processing-retry-limit.test.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/web/__tests__/unit/processing-retry-limit.test.ts b/apps/web/__tests__/unit/processing-retry-limit.test.ts index 430174d3c11..4a2a3fd0d28 100644 --- a/apps/web/__tests__/unit/processing-retry-limit.test.ts +++ b/apps/web/__tests__/unit/processing-retry-limit.test.ts @@ -135,7 +135,7 @@ describe("processing retry limit", () => { expect(mocks.startAiGeneration).not.toHaveBeenCalled(); }); - it("decides an agent rerun from the video's current state before any work starts", () => { + it("charges agent reruns from the video's current state, right before the work starts", () => { const source = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), "utf8", @@ -145,27 +145,27 @@ describe("processing retry limit", () => { start, source.indexOf('.handle("replaceTranscript"', start), ); - const limitCheck = handler.indexOf( - "processingRetryLimited(principal.id, headers)", - ); + const at = (text: string) => { + const index = handler.indexOf(text); + expect(index, text).toBeGreaterThan(-1); + return index; + }; expect(handler).toMatch( - /const transcriptionRerun =\s*includesTranscript &&\s*payload\.retry === true &&\s*row\.transcriptionStatus === "ERROR";/, + /processingRetryLimited\(principal\.id, headers\),\s*\)\s*\)\s*\{\s*return yield\* rateLimited\(\s*requestId,\s*PROCESSING_RETRY_WINDOW_MS,\s*\);/, ); expect(handler).toMatch( - /const aiRerun =\s*includesAi &&[\s\S]*?isAiGenerationRerun\(normalizeAgentMetadata\(row\.metadata\)\);/, + /includesTranscript &&\s*payload\.retry === true &&\s*row\.transcriptionStatus === "ERROR"\s*\)\s*\{\s*yield\* limitRerun;\s*\}\s*const state = yield\* runAgentMutation\(/, ); - expect(limitCheck).toBeGreaterThan( - handler.indexOf("if (transcriptionRerun || aiRerun)"), + expect(handler).toMatch( + /if \(isAiGenerationRerun\(normalizeAgentMetadata\(row\.metadata\)\)\) \{\s*yield\* limitRerun;\s*\}\s*const result = yield\* Effect\.tryPromise\(\(\) =>\s*startAiGeneration\(/, ); - expect(limitCheck).toBeGreaterThan( - handler.indexOf("isAiGenerationEnabled("), + expect(at("const limitRerun")).toBeGreaterThan( + at("isAiGenerationEnabled("), ); - expect(limitCheck).toBeLessThan(handler.indexOf("runAgentMutation(")); - expect(limitCheck).toBeLessThan(handler.indexOf("transcribeVideo(")); - expect(limitCheck).toBeLessThan(handler.indexOf("startAiGeneration(")); - expect(handler.slice(limitCheck)).toMatch( - /^processingRetryLimited\(principal\.id, headers\),\s*\)\s*\)\s*\{\s*return yield\* rateLimited\(\s*requestId,\s*PROCESSING_RETRY_WINDOW_MS,\s*\);/, + expect(at("yield* limitRerun")).toBeLessThan(at("transcribeVideo(")); + expect(at("runAgentMutation(")).toBeLessThan( + at("isAiGenerationRerun(normalizeAgentMetadata"), ); }); }); From 6b9d2ea6c6b0cd1a560751e0f84149d24ddd7cdb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:50:46 -0500 Subject: [PATCH 08/14] feat(web): decide which agent processing requests are charged as reruns --- apps/web/lib/generate-ai.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index 989029a82dd..43c0f682c18 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -26,6 +26,26 @@ export function isAiGenerationRerun(metadata: VideoMetadata) { ); } +export function chargesProcessingRerun(input: { + target: "all" | "transcript" | "ai"; + retry: boolean | undefined; + transcriptionStatus: string | null; + metadata: VideoMetadata; +}) { + if ( + input.target !== "ai" && + input.retry === true && + input.transcriptionStatus === "ERROR" + ) + return true; + if (input.target === "transcript") return false; + if (input.target === "all" && input.transcriptionStatus !== "COMPLETE") + return false; + if (!isAiGenerationRerun(input.metadata)) return false; + const status = input.metadata.aiGenerationStatus; + return input.retry === true || (status !== "ERROR" && status !== "SKIPPED"); +} + const getAffectedRows = (result: unknown) => { if (Array.isArray(result)) { return ( From bce35ee6cbb3317376fc2bfdb1266a53f750c62e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:50:46 -0500 Subject: [PATCH 09/14] fix(web): charge agent reruns on admission and skip reruns on replay --- apps/web/app/api/v1/[...route]/route.ts | 67 +++++++++++++------------ 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 8e5600f74c3..510b08b1bbd 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -160,7 +160,11 @@ import { completeDeveloperVideoUpload, createDeveloperVideoUpload, } from "@/lib/developer-video-upload"; -import { isAiGenerationRerun, startAiGeneration } from "@/lib/generate-ai"; +import { + chargesProcessingRerun, + isAiGenerationRerun, + startAiGeneration, +} from "@/lib/generate-ai"; import { canUseInfra } from "@/lib/infra-access"; import { autoTopUpSettingsError } from "@/lib/infra-auto-top-up"; import { @@ -7886,35 +7890,34 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( ); } } - // Reruns are decided from the video's current state rather than the - // request alone: an idempotent replay into a failed AI state still - // starts generation again, while a replay of a rerun that was - // already accepted finds the work queued and is not charged again. - const limitRerun = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - headers.set(name, value); - } - if ( - yield* Effect.promise(() => - processingRetryLimited(principal.id, headers), - ) - ) { - return yield* rateLimited( - requestId, - PROCESSING_RETRY_WINDOW_MS, - ); - } + // Reruns are judged from the video's current state. A new request is + // charged before the mutation, mirroring its conflict rules, so a + // refused request stores nothing. A replay of a saved request is not + // charged and never starts generation again on a failed video. + const metadata = normalizeAgentMetadata(row.metadata); + const chargesRerun = chargesProcessingRerun({ + target: payload.target, + retry: payload.retry, + transcriptionStatus: row.transcriptionStatus, + metadata, }); - if ( - includesTranscript && - payload.retry === true && - row.transcriptionStatus === "ERROR" - ) { - yield* limitRerun; - } + const headers = yield* requestHeaders; + let newRequest = false; const state = yield* runAgentMutation({ + admit: Effect.gen(function* () { + newRequest = true; + if ( + chargesRerun && + (yield* Effect.promise(() => + processingRetryLimited(principal.id, headers), + )) + ) { + return yield* rateLimited( + requestId, + PROCESSING_RETRY_WINDOW_MS, + ); + } + }), principal, operation: "process_cap", idempotencyKey: yield* requestIdempotencyKey, @@ -8012,10 +8015,10 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( if (!result.success) { return yield* temporarilyUnavailable(requestId, result.message); } - } else if (state.target !== "transcript") { - if (isAiGenerationRerun(normalizeAgentMetadata(row.metadata))) { - yield* limitRerun; - } + } else if ( + state.target !== "transcript" && + (newRequest || !isAiGenerationRerun(metadata)) + ) { const result = yield* Effect.tryPromise(() => startAiGeneration(state.id, state.ownerId), ); From 6a1e0e8d62c4e1e76bb8e62cee2b3267e75525e6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:50:46 -0500 Subject: [PATCH 10/14] test(web): cover agent rerun charging and replays --- .../unit/ai-generation-rerun.test.ts | 102 +++++++++++++++++- .../unit/processing-retry-limit.test.ts | 15 ++- 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/apps/web/__tests__/unit/ai-generation-rerun.test.ts b/apps/web/__tests__/unit/ai-generation-rerun.test.ts index 295e666713f..f821ad81e72 100644 --- a/apps/web/__tests__/unit/ai-generation-rerun.test.ts +++ b/apps/web/__tests__/unit/ai-generation-rerun.test.ts @@ -5,7 +5,7 @@ vi.mock("workflow/api", () => ({ start: vi.fn() })); vi.mock("@/lib/ai/provider", () => ({ isAiConfigured: () => true })); vi.mock("@/workflows/generate-ai", () => ({ generateAiWorkflow: vi.fn() })); -import { isAiGenerationRerun } from "@/lib/generate-ai"; +import { chargesProcessingRerun, isAiGenerationRerun } from "@/lib/generate-ai"; const legacySummary = "The AI was unable to generate a proper summary for this content."; @@ -50,3 +50,103 @@ describe("AI generation rerun", () => { ).toBe(true); }); }); + +describe("agent processing rerun charge", () => { + const complete = { + aiGenerationStatus: "COMPLETE" as const, + summary: "A summary", + chapters: [{ title: "Intro", start: 0 }], + }; + + it("does not charge first-time processing", () => { + for (const target of ["all", "transcript", "ai"] as const) { + expect( + chargesProcessingRerun({ + target, + retry: undefined, + transcriptionStatus: target === "ai" ? "COMPLETE" : null, + metadata: {}, + }), + ).toBe(false); + } + expect( + chargesProcessingRerun({ + target: "ai", + retry: true, + transcriptionStatus: "COMPLETE", + metadata: complete, + }), + ).toBe(false); + }); + + it("charges a transcript retry after a failure", () => { + for (const target of ["all", "transcript"] as const) { + expect( + chargesProcessingRerun({ + target, + retry: true, + transcriptionStatus: "ERROR", + metadata: {}, + }), + ).toBe(true); + } + expect( + chargesProcessingRerun({ + target: "transcript", + retry: undefined, + transcriptionStatus: "ERROR", + metadata: {}, + }), + ).toBe(false); + }); + + it("charges an AI retry after a failure or skip", () => { + for (const aiGenerationStatus of ["ERROR", "SKIPPED"] as const) { + for (const target of ["all", "ai"] as const) { + expect( + chargesProcessingRerun({ + target, + retry: true, + transcriptionStatus: "COMPLETE", + metadata: { aiGenerationStatus }, + }), + ).toBe(true); + } + } + }); + + it("does not charge a request the mutation refuses as a conflict", () => { + for (const aiGenerationStatus of ["ERROR", "SKIPPED"] as const) { + expect( + chargesProcessingRerun({ + target: "ai", + retry: undefined, + transcriptionStatus: "COMPLETE", + metadata: { aiGenerationStatus }, + }), + ).toBe(false); + } + }); + + it("charges a rerun of an incomplete result even without retry", () => { + expect( + chargesProcessingRerun({ + target: "ai", + retry: undefined, + transcriptionStatus: "COMPLETE", + metadata: { aiGenerationStatus: "COMPLETE", summary: "A summary" }, + }), + ).toBe(true); + }); + + it("does not charge AI work that waits on transcription", () => { + expect( + chargesProcessingRerun({ + target: "all", + retry: true, + transcriptionStatus: "PROCESSING", + metadata: { aiGenerationStatus: "ERROR" }, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/__tests__/unit/processing-retry-limit.test.ts b/apps/web/__tests__/unit/processing-retry-limit.test.ts index 4a2a3fd0d28..0c6276b4aaa 100644 --- a/apps/web/__tests__/unit/processing-retry-limit.test.ts +++ b/apps/web/__tests__/unit/processing-retry-limit.test.ts @@ -135,7 +135,7 @@ describe("processing retry limit", () => { expect(mocks.startAiGeneration).not.toHaveBeenCalled(); }); - it("charges agent reruns from the video's current state, right before the work starts", () => { + it("charges new agent reruns before the mutation and never reruns on a replay", () => { const source = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), "utf8", @@ -152,20 +152,17 @@ describe("processing retry limit", () => { }; expect(handler).toMatch( - /processingRetryLimited\(principal\.id, headers\),\s*\)\s*\)\s*\{\s*return yield\* rateLimited\(\s*requestId,\s*PROCESSING_RETRY_WINDOW_MS,\s*\);/, + /const chargesRerun = chargesProcessingRerun\(\{\s*target: payload\.target,\s*retry: payload\.retry,\s*transcriptionStatus: row\.transcriptionStatus,\s*metadata,\s*\}\);/, ); expect(handler).toMatch( - /includesTranscript &&\s*payload\.retry === true &&\s*row\.transcriptionStatus === "ERROR"\s*\)\s*\{\s*yield\* limitRerun;\s*\}\s*const state = yield\* runAgentMutation\(/, + /runAgentMutation\(\{\s*admit: Effect\.gen\(function\* \(\) \{\s*newRequest = true;\s*if \(\s*chargesRerun &&\s*\(yield\* Effect\.promise\(\(\) =>\s*processingRetryLimited\(principal\.id, headers\),\s*\)\)\s*\)\s*\{\s*return yield\* rateLimited\(\s*requestId,\s*PROCESSING_RETRY_WINDOW_MS,\s*\);/, ); expect(handler).toMatch( - /if \(isAiGenerationRerun\(normalizeAgentMetadata\(row\.metadata\)\)\) \{\s*yield\* limitRerun;\s*\}\s*const result = yield\* Effect\.tryPromise\(\(\) =>\s*startAiGeneration\(/, + /state\.target !== "transcript" &&\s*\(newRequest \|\| !isAiGenerationRerun\(metadata\)\)\s*\)\s*\{\s*const result = yield\* Effect\.tryPromise\(\(\) =>\s*startAiGeneration\(/, ); - expect(at("const limitRerun")).toBeGreaterThan( + expect(at("const chargesRerun")).toBeGreaterThan( at("isAiGenerationEnabled("), ); - expect(at("yield* limitRerun")).toBeLessThan(at("transcribeVideo(")); - expect(at("runAgentMutation(")).toBeLessThan( - at("isAiGenerationRerun(normalizeAgentMetadata"), - ); + expect(at("admit:")).toBeLessThan(at("transcribeVideo(")); }); }); From f12c52cbfb189abc13dc1b6cdacdfdf52164edf0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:05:44 -0500 Subject: [PATCH 11/14] fix(web): charge AI reruns only once the transcript is complete --- apps/web/lib/generate-ai.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index 43c0f682c18..228aafe9ffe 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -39,8 +39,7 @@ export function chargesProcessingRerun(input: { ) return true; if (input.target === "transcript") return false; - if (input.target === "all" && input.transcriptionStatus !== "COMPLETE") - return false; + if (input.transcriptionStatus !== "COMPLETE") return false; if (!isAiGenerationRerun(input.metadata)) return false; const status = input.metadata.aiGenerationStatus; return input.retry === true || (status !== "ERROR" && status !== "SKIPPED"); From 00d04c68cdc03fba1b7cfe103785a29ed88a2df8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:05:44 -0500 Subject: [PATCH 12/14] test(web): cover AI rerun charges while transcription is incomplete --- .../unit/ai-generation-rerun.test.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/__tests__/unit/ai-generation-rerun.test.ts b/apps/web/__tests__/unit/ai-generation-rerun.test.ts index f821ad81e72..a10316e86ba 100644 --- a/apps/web/__tests__/unit/ai-generation-rerun.test.ts +++ b/apps/web/__tests__/unit/ai-generation-rerun.test.ts @@ -140,13 +140,23 @@ describe("agent processing rerun charge", () => { }); it("does not charge AI work that waits on transcription", () => { - expect( - chargesProcessingRerun({ - target: "all", - retry: true, - transcriptionStatus: "PROCESSING", - metadata: { aiGenerationStatus: "ERROR" }, - }), - ).toBe(false); + for (const target of ["all", "ai"] as const) { + for (const transcriptionStatus of [ + null, + "PROCESSING", + "ERROR", + "SKIPPED", + "NO_AUDIO", + ]) { + expect( + chargesProcessingRerun({ + target, + retry: target === "ai" ? true : undefined, + transcriptionStatus, + metadata: { aiGenerationStatus: "ERROR" }, + }), + ).toBe(false); + } + } }); }); From 842c93c93f915c56474730895816f28bedf55acb Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 08:25:19 -0500 Subject: [PATCH 13/14] test(web): prove processing retries against a real database --- .../processing-retry-database.test.ts | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 apps/web/__tests__/integration/processing-retry-database.test.ts diff --git a/apps/web/__tests__/integration/processing-retry-database.test.ts b/apps/web/__tests__/integration/processing-retry-database.test.ts new file mode 100644 index 00000000000..7f879083c03 --- /dev/null +++ b/apps/web/__tests__/integration/processing-retry-database.test.ts @@ -0,0 +1,386 @@ +import { createHash, randomUUID } from "node:crypto"; +import * as Db from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import { createPool, type Pool } from "mysql2/promise"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +// Drives the real agent processCap handler and the real share page retry +// routes against a real migrated MySQL database. Only the session lookup, the +// transcription provider call and the AI workflow start are stubbed, so the +// idempotency store, the rerun charge, the rate limiter and startAiGeneration's +// own state checks all run for real. + +const fixture = vi.hoisted(() => ({ + database: undefined as unknown, + sessionUserId: "", + transcribe: vi.fn(), + startWorkflow: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database", async (importOriginal) => ({ + ...(await importOriginal()), + db: () => { + if (!fixture.database) throw new Error("Test database is not connected."); + return fixture.database; + }, +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => { + const [user] = await (fixture.database as MySql2Database) + .select() + .from(Db.users) + .where(eq(Db.users.id, fixture.sessionUserId as never)); + return user ?? null; + }, +})); +vi.mock("@cap/database/auth/request-site", () => ({ + isCrossSiteCookieWrite: () => false, +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/transcribe", () => ({ transcribeVideo: fixture.transcribe })); +vi.mock("@/lib/ai/provider", () => ({ isAiConfigured: () => true })); +vi.mock("workflow/api", () => ({ start: fixture.startWorkflow })); + +const databaseUrl = process.env.CAP_INFRA_TEST_DATABASE_URL; +const id = () => randomUUID().replaceAll("-", "").slice(0, 15); +const database = () => fixture.database as MySql2Database; + +type Owner = { userId: string; orgId: string; token: string }; + +const createOwner = async (): Promise => { + const userId = id(); + const orgId = id(); + const token = `cap_cli_${randomUUID().replaceAll("-", "")}${"a".repeat(11)}`; + await database() + .insert(Db.users) + .values({ + id: userId as never, + email: `${userId}@example.com`, + name: "Owner", + activeOrganizationId: orgId as never, + defaultOrgId: orgId as never, + stripeSubscriptionStatus: "active", + }); + await database() + .insert(Db.organizations) + .values({ id: orgId as never, name: "Team", ownerId: userId as never }); + await database() + .insert(Db.organizationMembers) + .values({ + id: id() as never, + userId: userId as never, + organizationId: orgId as never, + role: "owner", + }); + await database() + .insert(Db.agentApiKeys) + .values({ + id: id() as never, + userId: userId as never, + name: "Test agent", + tokenHash: createHash("sha256").update(token).digest("hex"), + scopes: ["caps:read", "caps:process"], + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + } as never); + return { userId, orgId, token }; +}; + +const createVideo = async ( + owner: Owner, + state: { + transcriptionStatus: "COMPLETE" | "ERROR" | null; + aiGenerationStatus?: string; + }, +) => { + const videoId = id(); + await database() + .insert(Db.videos) + .values({ + id: videoId as never, + ownerId: owner.userId as never, + orgId: owner.orgId as never, + name: "Recording", + transcriptionStatus: state.transcriptionStatus, + metadata: state.aiGenerationStatus + ? { aiGenerationStatus: state.aiGenerationStatus } + : {}, + } as never); + return videoId; +}; + +const videoState = async (videoId: string) => { + const [video] = await database() + .select({ + transcriptionStatus: Db.videos.transcriptionStatus, + metadata: Db.videos.metadata, + }) + .from(Db.videos) + .where(eq(Db.videos.id, videoId as never)); + return { + transcriptionStatus: video?.transcriptionStatus ?? null, + aiGenerationStatus: + (video?.metadata as { aiGenerationStatus?: string } | null) + ?.aiGenerationStatus ?? null, + }; +}; + +describe.runIf(Boolean(databaseUrl))( + "processing retries against a real database", + () => { + let pool: Pool; + let v1: typeof import("@/app/api/v1/[...route]/route").POST; + let retryAi: typeof import("@/app/api/videos/[videoId]/retry-ai/route").POST; + let retryTranscription: typeof import("@/app/api/videos/[videoId]/retry-transcription/route").POST; + + beforeAll(async () => { + if (!databaseUrl) throw new Error("Missing isolated test database URL."); + const url = new URL(databaseUrl); + if ( + url.protocol !== "mysql:" || + !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || + !/^\/cap_infra_[a-z0-9_]+$/.test(url.pathname) + ) + throw new Error("These tests require a session-scoped local database."); + pool = createPool({ uri: databaseUrl, connectionLimit: 8 }); + fixture.database = drizzle(pool); + Object.assign(process.env, { + NEXT_PUBLIC_WEB_URL: "http://localhost", + WEB_URL: "http://localhost", + DATABASE_URL: databaseUrl, + NEXTAUTH_SECRET: "synthetic-test-secret-that-is-long-enough", + NEXTAUTH_URL: "http://localhost", + CAP_AWS_BUCKET: "synthetic", + CAP_AWS_REGION: "us-east-1", + CAP_AWS_ACCESS_KEY: "synthetic", + CAP_AWS_SECRET_KEY: "synthetic", + NODE_ENV: "test", + }); + ({ POST: v1 } = await import("@/app/api/v1/[...route]/route")); + ({ POST: retryAi } = await import( + "@/app/api/videos/[videoId]/retry-ai/route" + )); + ({ POST: retryTranscription } = await import( + "@/app/api/videos/[videoId]/retry-transcription/route" + )); + }); + + beforeEach(() => { + fixture.transcribe.mockResolvedValue({ success: true, message: "ok" }); + fixture.startWorkflow.mockResolvedValue(undefined); + }); + + afterAll(async () => { + await pool?.end(); + }); + + const processCap = ( + owner: Owner, + videoId: string, + payload: { target: "all" | "transcript" | "ai"; retry?: boolean }, + key = randomUUID(), + ) => + v1( + new Request(`http://localhost/api/v1/caps/${videoId}/process`, { + method: "POST", + headers: { + Authorization: `Bearer ${owner.token}`, + "Content-Type": "application/json", + "Idempotency-Key": key, + "X-Cap-Confirmation": "user", + }, + body: JSON.stringify(payload), + }), + ); + + const sharePageRetry = ( + route: typeof retryAi, + owner: Owner, + videoId: string, + ) => { + fixture.sessionUserId = owner.userId; + return route( + new Request(`http://localhost/api/videos/${videoId}/retry`, { + method: "POST", + }), + { params: Promise.resolve({ videoId }) } as never, + ); + }; + + it("processes a fresh video through the agent API without charging a retry", async () => { + const owner = await createOwner(); + for (let index = 0; index < 35; index++) { + const videoId = await createVideo(owner, { + transcriptionStatus: "COMPLETE", + }); + fixture.startWorkflow.mockClear(); + const response = await processCap(owner, videoId, { target: "all" }); + expect(response.status, await response.clone().text()).toBe(200); + expect(fixture.startWorkflow).toHaveBeenCalledOnce(); + expect((await videoState(videoId)).aiGenerationStatus).toBe("QUEUED"); + } + + const pending = await createVideo(owner, { transcriptionStatus: null }); + fixture.transcribe.mockClear(); + const transcript = await processCap(owner, pending, { + target: "transcript", + }); + expect(transcript.status).toBe(200); + expect(fixture.transcribe).toHaveBeenCalledOnce(); + }); + + it("retries a failed transcription and a failed AI run once through the agent API", async () => { + const owner = await createOwner(); + const failedTranscript = await createVideo(owner, { + transcriptionStatus: "ERROR", + }); + fixture.transcribe.mockClear(); + const transcript = await processCap(owner, failedTranscript, { + target: "transcript", + retry: true, + }); + expect(transcript.status).toBe(200); + expect(fixture.transcribe).toHaveBeenCalledOnce(); + expect((await videoState(failedTranscript)).transcriptionStatus).toBe( + null, + ); + + const failedAi = await createVideo(owner, { + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "ERROR", + }); + fixture.startWorkflow.mockClear(); + const ai = await processCap(owner, failedAi, { + target: "ai", + retry: true, + }); + expect(ai.status).toBe(200); + expect(fixture.startWorkflow).toHaveBeenCalledOnce(); + expect((await videoState(failedAi)).aiGenerationStatus).toBe("QUEUED"); + }); + + it("retries a failed transcription and a failed AI run once from the share page", async () => { + const owner = await createOwner(); + const failedTranscript = await createVideo(owner, { + transcriptionStatus: "ERROR", + }); + const transcript = await sharePageRetry( + retryTranscription, + owner, + failedTranscript, + ); + expect(transcript.status).toBe(200); + expect((await videoState(failedTranscript)).transcriptionStatus).toBe( + null, + ); + + const failedAi = await createVideo(owner, { + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "ERROR", + }); + fixture.startWorkflow.mockClear(); + const ai = await sharePageRetry(retryAi, owner, failedAi); + expect(ai.status).toBe(200); + expect(fixture.startWorkflow).toHaveBeenCalledOnce(); + expect((await videoState(failedAi)).aiGenerationStatus).toBe("QUEUED"); + }); + + it("only refuses retries past 30 an hour, shared across the share page and the agent API", async () => { + const owner = await createOwner(); + for (let index = 0; index < 15; index++) { + const videoId = await createVideo(owner, { + transcriptionStatus: "ERROR", + }); + expect( + (await sharePageRetry(retryTranscription, owner, videoId)).status, + ).toBe(200); + } + for (let index = 0; index < 15; index++) { + const videoId = await createVideo(owner, { + transcriptionStatus: "ERROR", + }); + expect( + ( + await processCap(owner, videoId, { + target: "transcript", + retry: true, + }) + ).status, + ).toBe(200); + } + + const refusedAgent = await createVideo(owner, { + transcriptionStatus: "ERROR", + }); + const limited = await processCap(owner, refusedAgent, { + target: "transcript", + retry: true, + }); + expect(limited.status).toBe(429); + expect((await videoState(refusedAgent)).transcriptionStatus).toBe( + "ERROR", + ); + + const refusedSharePage = await createVideo(owner, { + transcriptionStatus: "ERROR", + }); + expect( + (await sharePageRetry(retryTranscription, owner, refusedSharePage)) + .status, + ).toBe(429); + expect((await videoState(refusedSharePage)).transcriptionStatus).toBe( + "ERROR", + ); + + const other = await createOwner(); + const otherVideo = await createVideo(other, { + transcriptionStatus: "ERROR", + }); + expect( + (await sharePageRetry(retryTranscription, other, otherVideo)).status, + ).toBe(200); + + const fresh = await createVideo(owner, { + transcriptionStatus: "COMPLETE", + }); + expect((await processCap(owner, fresh, { target: "all" })).status).toBe( + 200, + ); + }); + + it("replays a saved request without charging or starting AI again", async () => { + const owner = await createOwner(); + const failedAi = await createVideo(owner, { + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "ERROR", + }); + const key = randomUUID(); + fixture.startWorkflow.mockClear(); + expect( + (await processCap(owner, failedAi, { target: "ai", retry: true }, key)) + .status, + ).toBe(200); + expect(fixture.startWorkflow).toHaveBeenCalledOnce(); + + await database() + .update(Db.videos) + .set({ metadata: { aiGenerationStatus: "ERROR" } } as never) + .where(eq(Db.videos.id, failedAi as never)); + fixture.startWorkflow.mockClear(); + expect( + (await processCap(owner, failedAi, { target: "ai", retry: true }, key)) + .status, + ).toBe(200); + expect(fixture.startWorkflow).not.toHaveBeenCalled(); + }); + }, +); From 8e2af4ffdd079b4b172127d1e47da7f3ea399083 Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 08:31:29 -0500 Subject: [PATCH 14/14] test(web): type the share page retry helper for both routes --- .../web/__tests__/integration/processing-retry-database.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/__tests__/integration/processing-retry-database.test.ts b/apps/web/__tests__/integration/processing-retry-database.test.ts index 7f879083c03..bca8899e786 100644 --- a/apps/web/__tests__/integration/processing-retry-database.test.ts +++ b/apps/web/__tests__/integration/processing-retry-database.test.ts @@ -203,7 +203,7 @@ describe.runIf(Boolean(databaseUrl))( ); const sharePageRetry = ( - route: typeof retryAi, + route: (request: Request, props: never) => Promise, owner: Owner, videoId: string, ) => {