Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>(() => {}));
useDashboardStore.setState({ processVideo });
render(<OneLoopStudio showAgentWorkflowUi={false} />);
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<string>((resolve) => { finishes.push(resolve); }));
useDashboardStore.setState({ processVideo });
render(<OneLoopStudio showAgentWorkflowUi={false} />);
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<string>(() => {}));
useDashboardStore.setState({ processVideo });
render(<OneLoopStudio showAgentWorkflowUi={false} />);
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');
});
});
66 changes: 66 additions & 0 deletions apps/web/src/lib/__tests__/emit-video-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((_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<Response>((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 () => {
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/lib/__tests__/studio-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 30 additions & 11 deletions apps/web/src/lib/emit-video-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,26 @@ export function identityPackJson(pack: VideoPackCitation): string {
return JSON.stringify(pack.pack, null, 2);
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
function delay(ms: number, signal?: AbortSignal): Promise<void> {
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;
Expand All @@ -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<VideoPackCitation> {
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);
}
Expand All @@ -206,13 +223,14 @@ async function pollReadyPack(
throw new Error('Video pack emit timed out waiting for spec extract.');
}

function postVideoPack(url: string): Promise<Response> {
function postVideoPack(url: string, signal?: AbortSignal): Promise<Response> {
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),
});
}

Expand Down Expand Up @@ -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<VideoPackCitation> {
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.'));
}
3 changes: 2 additions & 1 deletion apps/web/src/lib/studio-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export async function startVideoToActions(input: {
events?: Array<{ type?: string; title: string; description?: string }>;
signal?: AbortSignal;
}): Promise<VideoToActionsStart> {
const timeout = AbortSignal.timeout(30_000);
const response = await fetch('/api/workflows/video-to-actions', {
method: 'POST',
credentials: 'same-origin',
Expand All @@ -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<string, unknown>;
Expand Down
Loading
Loading