From e9fc9e132fa47d6a281992fc1be204e4323f73eb Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:49:35 -0600 Subject: [PATCH 1/2] fix(studio): stop local analysis across every client stage --- apps/web/src/components/OneLoopStudio.tsx | 16 ++- .../OneLoopStudio.cancellation.test.tsx | 98 +++++++++++++++++++ .../src/lib/__tests__/emit-video-pack.test.ts | 66 +++++++++++++ .../src/lib/__tests__/studio-workflow.test.ts | 18 ++++ apps/web/src/lib/emit-video-pack.ts | 41 +++++--- apps/web/src/lib/studio-workflow.ts | 3 +- .../store/__tests__/dashboard-store.test.ts | 95 ++++++++++++++++++ apps/web/src/store/dashboard-store.ts | 53 +++++++--- apps/web/vercel.json | 8 ++ vercel.json | 6 ++ 10 files changed, 374 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/__tests__/OneLoopStudio.cancellation.test.tsx create mode 100644 apps/web/vercel.json diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index f04ba54f8..3f6e94a86 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -16,7 +16,7 @@ import { import { clsx } from 'clsx'; import Nav from '@/components/Nav'; import { StudioAuthNavLink } from '@/components/StudioAuthNavLink'; -import { dashboardPersistenceSucceeded, useDashboardStore } from '@/store/dashboard-store'; +import { ANALYSIS_STOPPED_MESSAGE, dashboardPersistenceSucceeded, useDashboardStore } from '@/store/dashboard-store'; import GroundedSpecReview from '@/components/GroundedSpecReview'; import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'; import { @@ -427,16 +427,20 @@ export default function OneLoopStudio({ setWorkflowActionSelectionId(null); setMessage('Fetching transcript…'); const tick = window.setInterval(() => { + if (controller.signal.aborted || runAbortRef.current !== controller) return; const matches = useDashboardStore .getState() .videos.filter((v) => v.url === next || v.url.includes(handoff.videoId)); if (matches.length === 0) return; selectVideo(matches[0].id); }, 250); + const stopTick = () => window.clearInterval(tick); + controller.signal.addEventListener('abort', stopTick, { once: true }); try { const id = await processVideo(next, { signal: controller.signal }); + if (runAbortRef.current !== controller) return; if (controller.signal.aborted) { - setMessage('Analysis cancelled.'); + setMessage(ANALYSIS_STOPPED_MESSAGE); return; } selectVideo(id); @@ -454,13 +458,15 @@ export default function OneLoopStudio({ ); } } catch (err) { + if (runAbortRef.current !== controller) return; if (controller.signal.aborted) { - setMessage('Analysis cancelled.'); + setMessage(ANALYSIS_STOPPED_MESSAGE); } else { setMessage(err instanceof Error ? err.message : 'Analysis failed.'); } } finally { window.clearInterval(tick); + controller.signal.removeEventListener('abort', stopTick); if (runAbortRef.current === controller) { runAbortRef.current = null; setBusy(false); @@ -470,7 +476,9 @@ export default function OneLoopStudio({ const cancelAnalysis = useCallback(() => { runAbortRef.current?.abort(); - setMessage('Cancelling analysis…'); + runAbortRef.current = null; + setBusy(false); + setMessage(ANALYSIS_STOPPED_MESSAGE); }, []); useEffect(() => { diff --git a/apps/web/src/components/__tests__/OneLoopStudio.cancellation.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.cancellation.test.tsx new file mode 100644 index 000000000..210789cb1 --- /dev/null +++ b/apps/web/src/components/__tests__/OneLoopStudio.cancellation.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import OneLoopStudio from '@/components/OneLoopStudio'; +import { useDashboardStore, type Video } from '@/store/dashboard-store'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); +vi.mock('@/components/Nav', () => ({ default: () => null })); +vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() })); +vi.mock('@/lib/use-youtube-player', () => ({ + useYouTubePlayer: () => ({ containerRef: { current: null }, ready: false, failed: false, seekTo: vi.fn() }), +})); + +const originalProcessVideo = useDashboardStore.getState().processVideo; +const video: Video = { + id: 'stored-analysis', + title: 'Stored analysis', + url: 'https://www.youtube.com/watch?v=auJzb1D-fag', + status: 'complete', + progress: 100, + transcript: 'Enough source transcript for the existing analysis in these tests.', +}; + +beforeEach(() => { + vi.stubGlobal('React', React); + vi.spyOn(useDashboardStore.persist, 'rehydrate').mockImplementation(() => undefined); + useDashboardStore.setState({ videos: [video], selectedVideoId: video.id }); +}); + +afterEach(() => { + cleanup(); + useDashboardStore.setState({ processVideo: originalProcessVideo }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('OneLoopStudio analysis cancellation', () => { + it('releases the run controls immediately and explains the server boundary', async () => { + const processVideo = vi.fn((_url: string, _opts?: { signal?: AbortSignal }) => new Promise(() => {})); + useDashboardStore.setState({ processVideo }); + render(); + fireEvent.change(screen.getByLabelText('YouTube URL'), { target: { value: video.url } }); + fireEvent.click(screen.getByTestId('studio-ide-run')); + expect(await screen.findByTestId('studio-ide-cancel')).toBeTruthy(); + + fireEvent.click(screen.getByTestId('studio-ide-cancel')); + + expect(processVideo.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect((screen.getByTestId('studio-ide-run') as HTMLButtonElement).disabled).toBe(false); + expect(screen.queryByTestId('studio-ide-cancel')).toBeNull(); + expect(screen.getByText(/Analysis stopped in this browser\. Server work already accepted may continue\./)).toBeTruthy(); + }); + + it('does not let a stopped run overwrite a newer run when its response arrives late', async () => { + const finishes: Array<(id: string) => void> = []; + const processVideo = vi.fn(() => new Promise((resolve) => { finishes.push(resolve); })); + useDashboardStore.setState({ processVideo }); + render(); + fireEvent.change(screen.getByLabelText('YouTube URL'), { target: { value: video.url } }); + fireEvent.click(screen.getByTestId('studio-ide-run')); + fireEvent.click(await screen.findByTestId('studio-ide-cancel')); + fireEvent.click(screen.getByTestId('studio-ide-run')); + expect(processVideo).toHaveBeenCalledTimes(2); + + await act(async () => { finishes[0](video.id); }); + + expect(screen.getByTestId('studio-ide-cancel')).toBeTruthy(); + expect(screen.getByTestId('studio-ide-status').textContent).toMatch(/^Working/); + expect(screen.queryByText(/Analysis cancelled/)).toBeNull(); + fireEvent.click(screen.getByTestId('studio-ide-cancel')); + await act(async () => { finishes[1](video.id); }); + expect(screen.queryByTestId('studio-ide-cancel')).toBeNull(); + expect(screen.getByTestId('studio-ide-status').textContent).toContain('Analysis stopped in this browser. Server work already accepted may continue.'); + }); + + it('stops the selection timer even if a stopped transport never settles', async () => { + vi.useFakeTimers(); + const processVideo = vi.fn(() => new Promise(() => {})); + useDashboardStore.setState({ processVideo }); + render(); + fireEvent.change(screen.getByLabelText('YouTube URL'), { target: { value: video.url } }); + fireEvent.click(screen.getByTestId('studio-ide-run')); + fireEvent.click(screen.getByTestId('studio-ide-cancel')); + act(() => { + useDashboardStore.getState().addVideo({ ...video, id: 'another-selection', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw' }); + useDashboardStore.getState().selectVideo('another-selection'); + }); + + await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); + + expect(useDashboardStore.getState().selectedVideoId).toBe('another-selection'); + }); +}); diff --git a/apps/web/src/lib/__tests__/emit-video-pack.test.ts b/apps/web/src/lib/__tests__/emit-video-pack.test.ts index ddad13d5f..cbd3dadc1 100644 --- a/apps/web/src/lib/__tests__/emit-video-pack.test.ts +++ b/apps/web/src/lib/__tests__/emit-video-pack.test.ts @@ -162,6 +162,72 @@ describe('emitVideoPack', () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('does not POST when analysis was already stopped', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(emitVideoPack(SOURCE_URL, { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts an active pack POST and does not start polling', async () => { + const controller = new AbortController(); + const fetchMock = vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + })); + vi.stubGlobal('fetch', fetchMock); + const pending = emitVideoPack(SOURCE_URL, { signal: controller.signal }); + const stopped = expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + + controller.abort(); + + expect(fetchMock.mock.calls[0]?.[1].signal?.aborted).toBe(true); + await stopped; + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('stops during the polling delay without waiting for the next request', async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const fetchMock = vi.fn().mockResolvedValue({ + status: 202, + json: async () => ({ status: 'processing', data: VALID.data }), + }); + vi.stubGlobal('fetch', fetchMock); + const pending = emitVideoPack(SOURCE_URL, { signal: controller.signal, pollIntervalMs: 30_000 }); + const stopped = expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + await Promise.resolve(); + await Promise.resolve(); + + controller.abort(); + + await stopped; + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not accept a late ready response after an active poll is stopped', async () => { + const controller = new AbortController(); + let resolvePoll!: (response: Response) => void; + const fetchMock = vi.fn() + .mockResolvedValueOnce({ status: 202, json: async () => ({ status: 'processing', data: VALID.data }) }) + .mockImplementationOnce(() => new Promise((resolve) => { resolvePoll = resolve; })); + vi.stubGlobal('fetch', fetchMock); + const pending = emitVideoPack(SOURCE_URL, { signal: controller.signal, pollIntervalMs: 0 }); + const stopped = expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + controller.abort(); + expect(fetchMock.mock.calls[1]?.[1].signal?.aborted).toBe(true); + resolvePoll({ status: 200, json: async () => VALID } as Response); + + await stopped; + expect(fetchMock).toHaveBeenCalledTimes(2); }); it('surfaces a 200 error envelope from POST', async () => { diff --git a/apps/web/src/lib/__tests__/studio-workflow.test.ts b/apps/web/src/lib/__tests__/studio-workflow.test.ts index 9a772ba2c..6fcfbc31d 100644 --- a/apps/web/src/lib/__tests__/studio-workflow.test.ts +++ b/apps/web/src/lib/__tests__/studio-workflow.test.ts @@ -128,6 +128,24 @@ describe('studio-workflow (WDK Product v1)', () => { vi.restoreAllMocks(); }); + it('keeps the workflow kickoff timeout when a caller can stop analysis', async () => { + const caller = new AbortController(); + const timeout = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, status: 200, + json: async () => ({ ok: true, runId: 'wrun_timeout_bound' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await startVideoToActions({ url: 'https://www.youtube.com/watch?v=auJzb1D-fag', signal: caller.signal }); + timeout.abort(new DOMException('Timed out', 'TimeoutError')); + + expect(timeoutSpy).toHaveBeenCalledWith(30_000); + expect(caller.signal.aborted).toBe(false); + expect(fetchMock.mock.calls[0]?.[1].signal.aborted).toBe(true); + }); + it('startVideoToActions parses runId and statusUrl', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/web/src/lib/emit-video-pack.ts b/apps/web/src/lib/emit-video-pack.ts index b1d80c34f..34ef0e455 100644 --- a/apps/web/src/lib/emit-video-pack.ts +++ b/apps/web/src/lib/emit-video-pack.ts @@ -152,12 +152,26 @@ export function identityPackJson(pack: VideoPackCitation): string { return JSON.stringify(pack.pack, null, 2); } -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); +function delay(ms: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); }); } +function packRequestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(15_000); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + function packErrorMessage(payload: unknown, fallback: string): string { const envelope = asRecord(payload); return typeof envelope?.error === 'string' ? envelope.error : fallback; @@ -181,20 +195,23 @@ function processingQuery(payload: unknown, fallbackUrl: string): string { async function pollReadyPack( payload: unknown, fallbackUrl: string, - options: { pollIntervalMs: number; timeoutMs: number }, + options: { pollIntervalMs: number; timeoutMs: number; signal?: AbortSignal }, ): Promise { const deadline = Date.now() + options.timeoutMs; const query = processingQuery(payload, fallbackUrl); while (Date.now() <= deadline) { + options.signal?.throwIfAborted(); if (options.pollIntervalMs > 0) { - await delay(options.pollIntervalMs); + await delay(options.pollIntervalMs, options.signal); } + options.signal?.throwIfAborted(); const response = await fetch(`/api/video/pack?${query}`, { method: 'GET', credentials: 'same-origin', - signal: AbortSignal.timeout(15_000), + signal: packRequestSignal(options.signal), }); const next: unknown = await response.json().catch(() => null); + options.signal?.throwIfAborted(); if (response.status === 200) { return verifyIdentityPack(next); } @@ -206,13 +223,14 @@ async function pollReadyPack( throw new Error('Video pack emit timed out waiting for spec extract.'); } -function postVideoPack(url: string): Promise { +function postVideoPack(url: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); return fetch('/api/video/pack', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), - signal: AbortSignal.timeout(15_000), + signal: packRequestSignal(signal), }); } @@ -240,17 +258,18 @@ export function startVideoPackEmit(watchUrl: string): void { export async function emitVideoPack( url: string, - options: { pollIntervalMs?: number; timeoutMs?: number } = {}, + options: { pollIntervalMs?: number; timeoutMs?: number; signal?: AbortSignal } = {}, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 1_000; const timeoutMs = options.timeoutMs ?? 120_000; - const response = await postVideoPack(url); + const response = await postVideoPack(url, options.signal); const payload: unknown = await response.json().catch(() => null); + options.signal?.throwIfAborted(); if (response.status === 200) { return verifyIdentityPack(payload); } if (response.status === 202) { - return pollReadyPack(payload, url, { pollIntervalMs, timeoutMs }); + return pollReadyPack(payload, url, { pollIntervalMs, timeoutMs, signal: options.signal }); } throw new Error(packErrorMessage(payload, 'Video pack emit failed.')); } diff --git a/apps/web/src/lib/studio-workflow.ts b/apps/web/src/lib/studio-workflow.ts index 2b9840e4d..6f935f89a 100644 --- a/apps/web/src/lib/studio-workflow.ts +++ b/apps/web/src/lib/studio-workflow.ts @@ -408,6 +408,7 @@ export async function startVideoToActions(input: { events?: Array<{ type?: string; title: string; description?: string }>; signal?: AbortSignal; }): Promise { + const timeout = AbortSignal.timeout(30_000); const response = await fetch('/api/workflows/video-to-actions', { method: 'POST', credentials: 'same-origin', @@ -418,7 +419,7 @@ export async function startVideoToActions(input: { ...(input.transcript ? { transcript: input.transcript } : {}), ...(input.events && input.events.length > 0 ? { events: input.events } : {}), }), - signal: input.signal ?? AbortSignal.timeout(30_000), + signal: input.signal ? AbortSignal.any([input.signal, timeout]) : timeout, }); const payload = (await response.json().catch(() => ({}))) as Record; diff --git a/apps/web/src/store/__tests__/dashboard-store.test.ts b/apps/web/src/store/__tests__/dashboard-store.test.ts index 0d4b692f3..d23edf7ab 100644 --- a/apps/web/src/store/__tests__/dashboard-store.test.ts +++ b/apps/web/src/store/__tests__/dashboard-store.test.ts @@ -200,6 +200,101 @@ describe('dashboard-store · processVideo (durable evidence workflow)', () => { checkedAt: '2026-08-16T12:00:02.000Z', }; + it('does not start a request when the caller has already stopped analysis', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const id = await store().processVideo(provenance.sourceUrl, { signal: controller.signal }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(store().videos.find((video) => video.id === id)?.failure?.message).toContain('Server work already accepted may continue'); + }); + + it('does not start the actions workflow when stopped as the pack completes', async () => { + const controller = new AbortController(); + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + json: async () => { + controller.abort(); + return VIDEO_PACK_BODY; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const id = await store().processVideo(provenance.sourceUrl, { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const video = store().videos.find((item) => item.id === id)!; + expect(video.status).toBe('failed'); + expect(video.videoPack).toBeUndefined(); + expect(video.runId).toBeUndefined(); + expect(video.failure?.message).toContain('stopped in this browser'); + }); + + it('stops the local row immediately while an aborted transport is still settling', async () => { + const controller = new AbortController(); + let rejectPack!: (error: Error) => void; + const fetchMock = vi.fn(() => new Promise((_resolve, reject) => { rejectPack = reject; })); + vi.stubGlobal('fetch', fetchMock); + const pending = store().processVideo(provenance.sourceUrl, { signal: controller.signal }); + + controller.abort(); + + expect(store().videos[0].status).toBe('failed'); + expect(store().videos[0].failure?.message).toContain('stopped in this browser'); + rejectPack(new TypeError('Network disconnected after stop')); + const id = await pending; + expect(store().videos.find((item) => item.id === id)?.failure?.message).not.toContain('Network disconnected'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('forwards stop to workflow kickoff and does not poll a late accepted run', async () => { + const controller = new AbortController(); + let resolveStart!: (response: Response) => void; + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(VIDEO_PACK_BODY)) + .mockImplementationOnce(() => new Promise((resolve) => { resolveStart = resolve; })); + vi.stubGlobal('fetch', fetchMock); + const pending = store().processVideo(provenance.sourceUrl, { signal: controller.signal }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + controller.abort(); + expect(fetchMock.mock.calls[1]?.[1].signal?.aborted).toBe(true); + resolveStart(jsonResponse({ ok: true, runId: 'wrun_accepted_before_stop' })); + const id = await pending; + + expect(fetchMock).toHaveBeenCalledTimes(2); + const video = store().videos.find((item) => item.id === id)!; + expect(video.status).toBe('failed'); + expect(video.runId).toBe('wrun_accepted_before_stop'); + expect(video.failure?.message).toContain('Server work already accepted may continue'); + }); + + it('does not mark a late completed poll as successful after analysis is stopped', async () => { + const controller = new AbortController(); + let resolvePoll!: (response: Response) => void; + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(VIDEO_PACK_BODY)) + .mockResolvedValueOnce(jsonResponse({ ok: true, runId: 'wrun_stopped' })) + .mockImplementationOnce(() => new Promise((resolve) => { resolvePoll = resolve; })); + vi.stubGlobal('fetch', fetchMock); + const pending = store().processVideo(provenance.sourceUrl, { signal: controller.signal }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + + controller.abort(); + expect(fetchMock.mock.calls[2]?.[1].signal?.aborted).toBe(true); + resolvePoll(jsonResponse({ ok: true, runId: 'wrun_stopped', runStatus: 'completed', result: {} })); + const id = await pending; + + const video = store().videos.find((item) => item.id === id)!; + expect(video.status).toBe('failed'); + expect(video.progress).toBeLessThan(100); + expect(video.quality).toBeUndefined(); + expect(video.failure?.message).toContain('stopped in this browser'); + }); + it('persists the run id and maps only a verified result to complete', async () => { const fetchMock = vi .fn() diff --git a/apps/web/src/store/dashboard-store.ts b/apps/web/src/store/dashboard-store.ts index 2ddc62b91..59738a849 100644 --- a/apps/web/src/store/dashboard-store.ts +++ b/apps/web/src/store/dashboard-store.ts @@ -36,6 +36,9 @@ export type { Video, } from '@/store/dashboard-types'; +export const ANALYSIS_STOPPED_MESSAGE = + 'Analysis stopped in this browser. Server work already accepted may continue.'; + interface DashboardState { // Data videos: Video[]; @@ -345,11 +348,38 @@ export const useDashboardStore = create()( pipelineMode: 'workflow', }); + let locallyStopped = false; + const stopLocalAnalysis = () => { + if (locallyStopped) return; + locallyStopped = true; + updateVideo(id, { + status: 'failed', + title: `Analysis stopped: ${truncate(url, 38)}`, + failure: { + stage: 'analysis', + message: ANALYSIS_STOPPED_MESSAGE, + retryable: true, + failedAt: new Date().toISOString(), + }, + }); + }; + signal?.addEventListener('abort', stopLocalAnalysis, { once: true }); + try { - const videoPack = await emitVideoPack(url); + signal?.throwIfAborted(); + const videoPack = await emitVideoPack(url, { signal }); + signal?.throwIfAborted(); updateVideo(id, { videoPack, progress: 5 }); - const started = await startVideoToActions({ url }); + const started = await startVideoToActions({ url, signal }); + // Preserve a known accepted run for later readback, even if local waiting stopped. + if (started.runId) { + updateVideo(id, { + runId: started.runId, + statusUrl: started.statusUrl || `/api/workflows/video-to-actions/${encodeURIComponent(started.runId)}`, + }); + } + signal?.throwIfAborted(); if (!started.ok || !started.runId) { throw new Error( `Workflow start failed: ${started.error || started.message || 'no run ID returned.'}`, @@ -369,18 +399,7 @@ export const useDashboardStore = create()( signal, }); - if (signal?.aborted) { - updateVideo(id, { - status: 'failed', - failure: { - stage: 'analysis', - message: 'Cancelled by user.', - retryable: true, - failedAt: new Date().toISOString(), - }, - }); - return id; - } + signal?.throwIfAborted(); if (terminal.runStatus !== 'completed') { if (terminal.runStatus === 'running' || terminal.runStatus === 'pending') { @@ -392,8 +411,14 @@ export const useDashboardStore = create()( updateVideo(id, verifiedResultPatch(id, started.runId, terminal.result, videoPack)); } catch (error) { + if (signal?.aborted) { + stopLocalAnalysis(); + return id; + } const message = error instanceof Error ? error.message : 'Analysis failed.'; updateVideo(id, workflowFailurePatch(url, startedAt, message)); + } finally { + signal?.removeEventListener('abort', stopLocalAnalysis); } return id; diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 000000000..8de7e294a --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,8 @@ +{ + "git": { + "deploymentEnabled": { + "codex/fix-pack-readiness-20261112": false, + "codex/fix-studio-cancellation-20261112": false + } + } +} diff --git a/vercel.json b/vercel.json index d77369556..498708ad7 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,10 @@ { + "git": { + "deploymentEnabled": { + "codex/fix-pack-readiness-20261112": false, + "codex/fix-studio-cancellation-20261112": false + } + }, "framework": "nextjs", "installCommand": "npm install --legacy-peer-deps", "buildCommand": "npm run build", From 81dcf15e49cdfe29efe3423374ac098f5d3f1234 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:30:09 -0600 Subject: [PATCH 2/2] chore: enable authorized Studio preview deployments --- apps/web/vercel.json | 8 -------- vercel.json | 6 ------ 2 files changed, 14 deletions(-) delete mode 100644 apps/web/vercel.json diff --git a/apps/web/vercel.json b/apps/web/vercel.json deleted file mode 100644 index 8de7e294a..000000000 --- a/apps/web/vercel.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "git": { - "deploymentEnabled": { - "codex/fix-pack-readiness-20261112": false, - "codex/fix-studio-cancellation-20261112": false - } - } -} diff --git a/vercel.json b/vercel.json index 498708ad7..d77369556 100644 --- a/vercel.json +++ b/vercel.json @@ -1,10 +1,4 @@ { - "git": { - "deploymentEnabled": { - "codex/fix-pack-readiness-20261112": false, - "codex/fix-studio-cancellation-20261112": false - } - }, "framework": "nextjs", "installCommand": "npm install --legacy-peer-deps", "buildCommand": "npm run build",