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
1 change: 1 addition & 0 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@
const started = await startVideoToActions(payload);
if (!started.ok || !started.runId) {
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`;

Check warning on line 456 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 456 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
setMessage(started.error || started.message || 'Could not start Act.');
Expand All @@ -466,6 +466,7 @@
: `Act ${started.runId} started.`,
);
const polled = await pollVideoToActions(started.runId, {
statusUrl: started.statusUrl,
attempts: 24,
delayMs: 2000,
});
Expand Down Expand Up @@ -565,7 +566,7 @@
try {
const started = await startStudioDeploy({ url: next });
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`;

Check warning on line 569 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 569 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
if (!started.ok || !started.runId) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/VideoWorkflowStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@
setActionMessage(`Workflow ${started.runId} running — polling transcript + actions…`);

const polled = await pollVideoToActions(started.runId, {
statusUrl: started.statusUrl,
attempts: 24,
delayMs: 2000,
});
Expand Down Expand Up @@ -609,7 +610,7 @@
});
if (started.status === 401 || started.status === 403) {
setActionMessage('Sign in to deploy. Redirecting to Google sign-in…');
window.location.assign('/login?callbackUrl=/studio');

Check warning on line 613 in apps/web/src/components/VideoWorkflowStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.assign()` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 613 in apps/web/src/components/VideoWorkflowStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.assign()` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
if (started.status === 400) {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/lib/__tests__/studio-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,36 @@ describe('studio-workflow (WDK Product v1)', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('pollVideoToActions uses the durable statusUrl returned at start', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
ok: true,
runId: 'wrun_status_url',
runStatus: 'completed',
result: {
url: 'https://youtu.be/x',
transcriptChars: 50,
actionCount: 0,
actions: [],
},
}),
});
vi.stubGlobal('fetch', fetchMock);

await pollVideoToActions('wrun_status_url', {
statusUrl: '/api/workflows/video-to-actions/wrun_status_url',
attempts: 1,
delayMs: 1,
});

expect(fetchMock).toHaveBeenCalledWith(
'/api/workflows/video-to-actions/wrun_status_url',
expect.any(Object),
);
});

it('startStudioDeploy succeeds when the route returns a runId', async () => {
vi.stubGlobal(
'fetch',
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/lib/studio-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export interface VideoToActionsPoll {
message?: string;
}

function videoToActionsStatusUrl(runId: string, statusUrl?: string): string {
return statusUrl?.startsWith('/api/workflows/video-to-actions/')
? statusUrl
: `/api/workflows/video-to-actions/${encodeURIComponent(runId)}`;
}

function str(v: unknown): string | undefined {
return typeof v === 'string' && v.trim() ? v : undefined;
}
Expand Down Expand Up @@ -226,10 +232,10 @@ export async function startVideoToActions(input: {
/** Single status poll for a workflow run. */
export async function getVideoToActionsStatus(
runId: string,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; statusUrl?: string },
): Promise<VideoToActionsPoll> {
const response = await fetch(
`/api/workflows/video-to-actions/${encodeURIComponent(runId)}`,
videoToActionsStatusUrl(runId, opts?.statusUrl),
{
method: 'GET',
credentials: 'same-origin',
Expand Down Expand Up @@ -300,7 +306,7 @@ export async function getVideoToActionsStatus(
*/
export async function pollVideoToActions(
runId: string,
opts?: { attempts?: number; delayMs?: number; signal?: AbortSignal },
opts?: { attempts?: number; delayMs?: number; signal?: AbortSignal; statusUrl?: string },
): Promise<VideoToActionsPoll> {
const attempts = opts?.attempts ?? 30;
const delayMs = opts?.delayMs ?? 2000;
Expand All @@ -315,7 +321,10 @@ export async function pollVideoToActions(
if (opts?.signal?.aborted) {
return { ...last, error: last.error || 'aborted', message: 'Polling aborted' };
}
last = await getVideoToActionsStatus(runId, { signal: opts?.signal });
last = await getVideoToActionsStatus(runId, {
signal: opts?.signal,
statusUrl: opts?.statusUrl,
});
if (last.runStatus && TERMINAL.has(last.runStatus)) {
return last;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/store/dashboard-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ export const useDashboardStore = create<DashboardState>()(
addActivity(`Durable run created: ${started.runId}`, 'success');

const terminal = await pollVideoToActions(started.runId, {
statusUrl: started.statusUrl,
attempts: 180,
delayMs: 2000,
});
Expand Down Expand Up @@ -422,6 +423,7 @@ export const useDashboardStore = create<DashboardState>()(
activeRunResumptions.add(runId);
try {
const terminal = await pollVideoToActions(runId, {
statusUrl: video.statusUrl,
attempts: 180,
delayMs: 2000,
});
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/workflows/__tests__/video-to-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';

const runActionAgent = vi.fn().mockResolvedValue({
provider: 'gateway:test',
actions: [
{
tool: 'create_workflow_task',
status: 'pending',
result: 'Prepared for review.',
},
],
});

vi.mock('@/lib/action-agent', () => ({ runActionAgent }));
vi.mock('@/lib/transcription-service', () => ({
fetchTranscript: vi.fn().mockResolvedValue({
transcript: 'fetched transcript with enough words to pass the evidence gate',
segments: [{ start: 0, duration: 1, text: 'fetched transcript' }],
sourceUrl: 'https://www.youtube.com/watch?v=auJzb1D-fag',
source: 'youtube',
verified: true,
}),
}));
vi.mock('@/lib/gemini-video-analyzer', () => ({
analyzeVideoWithGemini: vi.fn().mockResolvedValue({
title: 'Fixture',
summary: 'Summary',
transcript: [],
events: [],
actions: [],
topics: [],
architectureCode: '',
ingestScript: '',
e22Snippets: [],
provenance: {
sourceUrl: 'https://www.youtube.com/watch?v=auJzb1D-fag',
sourceHost: 'www.youtube.com',
acquisitionMethod: 'captions',
transcriptSource: 'youtube',
transcriptVerified: true,
acquiredAt: new Date().toISOString(),
segmentCount: 1,
timedSegmentCount: 1,
durationCoverageSeconds: 1,
contentSha256: 'hash',
warnings: [],
},
quality: { passed: true, state: 'verified', issues: [] },
}),
}));
vi.mock('@/lib/gemini-client', () => ({
getGeminiRoutingLabel: vi.fn().mockResolvedValue('gateway:test'),
}));

describe('videoToActionsWorkflow', () => {
it('sends same-run transcript and events to the preview action agent', async () => {
const { videoToActionsWorkflow } = await import('../video-to-actions');
const transcript = 'provided Analyze transcript with enough words to pass the gate';

const result = await videoToActionsWorkflow({
url: 'https://www.youtube.com/watch?v=auJzb1D-fag',
videoTitle: 'Fixture',
transcript,
events: [{ type: 'action', title: 'Ship', description: 'now' }],
});

expect(runActionAgent).toHaveBeenCalledWith(
expect.objectContaining({
transcript: expect.stringContaining(transcript),
videoTitle: 'Fixture',
executeTools: false,
}),
);
expect(result.usedProvidedTranscript).toBe(true);
expect(result.actions[0]?.tool).toBe('create_workflow_task');
});
});
46 changes: 40 additions & 6 deletions apps/web/src/workflows/video-to-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type TranscriptSegment,
} from '@/lib/analysis-evidence';
import type { VideoAnalysisResult, VerifiedVideoEvidence } from '@/lib/gemini-video-analyzer';
import { buildActionAgentSource, usableProvidedTranscript } from '@/lib/video-to-actions-input';

export interface VideoToActionsEvent {
type?: string;
Expand Down Expand Up @@ -68,25 +69,58 @@ export async function videoToActionsWorkflow(
throw new FatalError('Analysis quality gate failed: missing provenance');
}

const provider = await providerLabelStep();
const actions = (analysis.actions || []).map((action) => ({
tool: 'review_action',
status: 'proposed',
result: action.title,
const providedTranscript = usableProvidedTranscript(input.transcript);
const actionAgent = await actionAgentStep(
providedTranscript || evidence.transcript,
input.videoTitle,
input.events,
);
const provider = actionAgent.provider || (await providerLabelStep());
const actions = actionAgent.actions.map((action) => ({
tool: action.tool,
status: action.status,
result: action.result,
}));

return {
url,
transcriptChars: evidence.transcript.length,
actionCount: analysis.actions?.length || 0,
actionCount: actions.length,
provider,
usedProvidedTranscript: Boolean(providedTranscript),
actions,
analysis,
provenance,
quality,
};
}

async function actionAgentStep(
transcript: string,
videoTitle?: string,
events?: VideoToActionsEvent[],
): Promise<{
provider: string;
actions: Array<{ tool: string; status: string; result?: string }>;
}> {
'use step';

const { runActionAgent } = await import('@/lib/action-agent');
const result = await runActionAgent({
transcript: buildActionAgentSource(transcript, events),
videoTitle,
executeTools: false,
});
return {
provider: result.provider,
actions: result.actions.map((action) => ({
tool: action.tool,
status: action.status,
result: action.result,
})),
};
}

async function transcribeStep(url: string): Promise<VerifiedVideoEvidence> {
'use step';

Expand Down
Loading