Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { NextResponse } from 'next/server';
import { getRun } from 'workflow/api';
import type { VideoToActionsResult } from '@/workflows/video-to-actions';

export const runtime = 'nodejs';

/**
* GET /api/workflows/video-to-actions/:runId
*
* Poll durable Workflow DevKit run status + result (when completed).
*/
export async function GET(
_request: Request,
context: { params: Promise<{ runId: string }> },
): Promise<NextResponse> {
const { runId: raw } = await context.params;
const runId = typeof raw === 'string' ? raw.trim() : '';
if (!runId || runId.length > 200) {
return NextResponse.json({ error: 'runId is required' }, { status: 400 });
}

try {
const run = getRun<VideoToActionsResult>(runId);
const exists = await run.exists;
if (!exists) {
return NextResponse.json(
{ ok: false, runId, error: 'Workflow run not found' },
{ status: 404 },
);
}

const runStatus = await run.status;
const payload: Record<string, unknown> = {
ok: true,
runId,
runStatus,
workflowName: await run.workflowName.catch(() => undefined),
createdAt: await run.createdAt.then((d) => d.toISOString()).catch(() => undefined),
startedAt: await run.startedAt
.then((d) => d?.toISOString())
.catch(() => undefined),
completedAt: await run.completedAt
.then((d) => d?.toISOString())
.catch(() => undefined),
};

if (runStatus === 'completed') {
try {
payload.result = await run.returnValue;
} catch (err) {
payload.error =
err instanceof Error ? err.message : 'Failed to read workflow return value';
}
} else if (runStatus === 'failed') {
// Best-effort: some worlds attach the failure on returnValue rejection.
try {
payload.result = await Promise.race([
run.returnValue,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 500),
),
]);
} catch (err) {
payload.error =
err instanceof Error && err.message !== 'timeout'
? err.message
: 'Workflow run failed';
}
}

return NextResponse.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// getRun may throw when the world cannot resolve the id.
if (/not found|does not exist/i.test(message)) {
return NextResponse.json(
{ ok: false, runId, error: 'Workflow run not found' },
{ status: 404 },
);
}
console.error('[api/workflows/video-to-actions/:runId]', err);
return NextResponse.json(
{
ok: false,
runId,
error: message,
hint: 'Ensure the workflow package is installed and withWorkflow wraps next.config.',
},
{ status: 500 },
);
}
}
32 changes: 29 additions & 3 deletions apps/web/src/app/api/workflows/video-to-actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import { NextResponse } from 'next/server';
import { start } from 'workflow/api';
import { videoToActionsWorkflow } from '@/workflows/video-to-actions';

export const runtime = 'nodejs';
/** start() returns quickly; the durable run continues in the workflow world. */
export const maxDuration = 60;

/**
* POST /api/workflows/video-to-actions
*
* Starts a durable Workflow DevKit run for video → transcript → actions.
* Returns immediately with { runId }; inspect via `npx workflow web`.
* Returns immediately with { runId }; poll GET .../:runId or use `npx workflow web`.
*/
export async function POST(request: Request): Promise<NextResponse> {
let body: { url?: unknown; videoTitle?: unknown };
Expand All @@ -24,16 +28,38 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

// Reject obviously private/local targets at the edge (SSRF defense-in-depth;
// assertPublicHttpUrl still runs inside transcription when audioUrl is used).
try {
const host = new URL(url).hostname.toLowerCase();
if (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '0.0.0.0' ||
host === '::1' ||
host.endsWith('.local') ||
host.endsWith('.internal')
) {
return NextResponse.json(
{ error: 'url host is not allowed' },
{ status: 400 },
);
}
} catch {
return NextResponse.json({ error: 'url is not a valid URL' }, { status: 400 });
}

const videoTitle =
typeof body.videoTitle === 'string' ? body.videoTitle : undefined;
typeof body.videoTitle === 'string' ? body.videoTitle.slice(0, 200) : undefined;

try {
const run = await start(videoToActionsWorkflow, [{ url, videoTitle }]);
return NextResponse.json({
ok: true,
runId: run.runId,
statusUrl: `/api/workflows/video-to-actions/${encodeURIComponent(run.runId)}`,
message:
'Durable video-to-actions workflow started. Inspect with: npx workflow web',
'Durable video-to-actions workflow started. Poll statusUrl or run: npx workflow web',
});
} catch (err) {
console.error('[api/workflows/video-to-actions]', err);
Expand Down
139 changes: 131 additions & 8 deletions apps/web/src/components/VideoWorkflowStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import {
type StudioRunQuality,
} from '@/lib/studio-pipeline-status';
import { kickoffStudioDeploy, pollStudioJob } from '@/lib/studio-deploy';
import {
pollVideoToActions,
startVideoToActions,
type VideoToActionsResult,
} from '@/lib/studio-workflow';

type OutcomeId = 'app' | 'sop' | 'lesson' | 'research' | 'automation' | 'content';
type RunState = 'idle' | 'working' | 'ready';
Expand Down Expand Up @@ -373,6 +378,10 @@ export default function VideoWorkflowStudio() {
const [deployJobId, setDeployJobId] = useState<string | null>(null);
const [deployLiveUrl, setDeployLiveUrl] = useState<string | null>(null);
const [deployRepo, setDeployRepo] = useState<string | null>(null);
/** Durable WDK video→actions (Product v1) — separate from FastAPI pipeline jobs. */
const [actionsBusy, setActionsBusy] = useState(false);
const [workflowRunId, setWorkflowRunId] = useState<string | null>(null);
const [workflowActions, setWorkflowActions] = useState<VideoToActionsResult | null>(null);

const audioRef = useRef<HTMLAudioElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -497,6 +506,75 @@ export default function VideoWorkflowStudio() {
}, unsafe ? 250 : 100);
};

/**
* Act on findings — durable Workflow DevKit path (video → transcript → action agent).
* Survives reloads better than a single long request; poll by runId.
*/
const handleActOnFindings = async () => {
if (actionsBusy) return;
const currentVideoUrl = videoUrlRef.current || videoUrl;
const currentVideoId = getYouTubeId(currentVideoUrl);
if (!currentVideoId) {
setActionMessage('Add a valid YouTube URL before acting on findings.');
return;
}

setActionsBusy(true);
setWorkflowActions(null);
setActionMessage('Starting durable video→actions workflow…');

try {
const started = await startVideoToActions({
url: currentVideoUrl,
videoTitle: selectedOutcomeLabel,
});
if (!started.ok || !started.runId) {
setActionMessage(
started.error
? `Could not start durable workflow: ${started.error}`
: 'Could not start durable workflow. Check Workflow DevKit install and withWorkflow config.',
);
return;
}

setWorkflowRunId(started.runId);
setActionMessage(`Workflow ${started.runId} running — polling transcript + actions…`);

const polled = await pollVideoToActions(started.runId, {
attempts: 24,
delayMs: 2000,
});

if (polled.runStatus === 'completed' && polled.result) {
setWorkflowActions(polled.result);
const n = polled.result.actionCount;
setActionMessage(
n > 0
? `Acted on findings: ${n} action${n === 1 ? '' : 's'} via ${polled.result.provider || 'agent'} (run ${started.runId}).`
: `Workflow finished with no tool actions (run ${started.runId}). Transcript ${polled.result.transcriptChars} chars.`,
);
return;
}

if (polled.runStatus === 'failed' || polled.runStatus === 'cancelled') {
setActionMessage(
`Workflow ${started.runId} ${polled.runStatus}${polled.error ? `: ${polled.error}` : ''}. Try Dashboard live analysis or re-run.`,
);
return;
}

setActionMessage(
`Workflow ${started.runId} still ${polled.runStatus || 'running'}. Re-check later or open Dashboard for SSE pipeline.`,
);
} catch (err) {
setActionMessage(
`Act on findings failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setActionsBusy(false);
}
};

const handleDeploy = async () => {
if (deployBusy) return;
const currentVideoUrl = videoUrlRef.current || videoUrl;
Expand Down Expand Up @@ -660,18 +738,63 @@ export default function VideoWorkflowStudio() {
<div className="text-sm text-slate-600">
<span className="font-semibold text-slate-950">Studio</span> builds local planning drafts.
{' '}
<span className="font-semibold text-slate-950">Dashboard</span> runs the live agent pipeline (transcript, actions, agents).
<span className="font-semibold text-slate-950">Act on findings</span> runs a durable video→transcript→actions workflow.
{' '}
<span className="font-semibold text-slate-950">Dashboard</span> runs the live SSE agent pipeline.
{' '}
<span className="font-semibold text-slate-950">Prototype</span> is a design walkthrough — not connected to production APIs.
</div>
<Link
href={dashboardHandoffUrl}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 transition hover:bg-blue-100"
>
Open live analysis
<ChevronRight className="h-4 w-4" />
</Link>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleActOnFindings()}
disabled={actionsBusy || !hasVideo}
aria-busy={actionsBusy || undefined}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2 text-sm font-semibold text-violet-800 transition hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<Layers className="h-4 w-4" aria-hidden="true" />
{actionsBusy ? 'Acting…' : 'Act on findings'}
</button>
<Link
href={dashboardHandoffUrl}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 transition hover:bg-blue-100"
>
Open live analysis
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</div>
{workflowRunId ? (
<div className="mt-3 space-y-2">
<p className="text-xs text-slate-500">
Durable run{' '}
<code className="rounded bg-slate-100 px-1 py-0.5 font-mono text-[11px] text-slate-700">
{workflowRunId}
</code>
{workflowActions
? ` · ${workflowActions.actionCount} action(s) · ${workflowActions.transcriptChars} transcript chars`
: actionsBusy
? ' · running…'
: null}
</p>
{workflowActions && workflowActions.actions.length > 0 ? (
<ul className="grid gap-1.5 sm:grid-cols-2">
{workflowActions.actions.slice(0, 8).map((a, i) => (
<li
key={`${a.tool}-${i}`}
className="rounded-lg border border-violet-100 bg-violet-50/60 px-3 py-2 text-xs text-slate-700"
>
<span className="font-semibold text-violet-900">{a.tool}</span>
<span className="text-slate-400"> · {a.status}</span>
{a.result ? (
<p className="mt-0.5 line-clamp-2 text-slate-600">{a.result}</p>
) : null}
</li>
))}
</ul>
) : null}
</div>
) : null}
</div>

<section className="space-y-5">
Expand Down
Loading
Loading