diff --git a/apps/web/src/app/api/workflows/video-to-actions/[runId]/route.ts b/apps/web/src/app/api/workflows/video-to-actions/[runId]/route.ts new file mode 100644 index 000000000..c6d790b0d --- /dev/null +++ b/apps/web/src/app/api/workflows/video-to-actions/[runId]/route.ts @@ -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 { + 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(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 = { + 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 }, + ); + } +} diff --git a/apps/web/src/app/api/workflows/video-to-actions/route.ts b/apps/web/src/app/api/workflows/video-to-actions/route.ts index 9aff87e22..a33301d07 100644 --- a/apps/web/src/app/api/workflows/video-to-actions/route.ts +++ b/apps/web/src/app/api/workflows/video-to-actions/route.ts @@ -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 { let body: { url?: unknown; videoTitle?: unknown }; @@ -24,16 +28,38 @@ export async function POST(request: Request): Promise { ); } + // 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); diff --git a/apps/web/src/components/VideoWorkflowStudio.tsx b/apps/web/src/components/VideoWorkflowStudio.tsx index 322fb709b..b9342e0b7 100644 --- a/apps/web/src/components/VideoWorkflowStudio.tsx +++ b/apps/web/src/components/VideoWorkflowStudio.tsx @@ -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'; @@ -373,6 +378,10 @@ export default function VideoWorkflowStudio() { const [deployJobId, setDeployJobId] = useState(null); const [deployLiveUrl, setDeployLiveUrl] = useState(null); const [deployRepo, setDeployRepo] = useState(null); + /** Durable WDK video→actions (Product v1) — separate from FastAPI pipeline jobs. */ + const [actionsBusy, setActionsBusy] = useState(false); + const [workflowRunId, setWorkflowRunId] = useState(null); + const [workflowActions, setWorkflowActions] = useState(null); const audioRef = useRef(null); const timerRef = useRef | null>(null); @@ -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; @@ -660,18 +738,63 @@ export default function VideoWorkflowStudio() {
Studio builds local planning drafts. {' '} - Dashboard runs the live agent pipeline (transcript, actions, agents). + Act on findings runs a durable video→transcript→actions workflow. + {' '} + Dashboard runs the live SSE agent pipeline. {' '} Prototype is a design walkthrough — not connected to production APIs.
- - Open live analysis - - +
+ + + Open live analysis + + +
+ {workflowRunId ? ( +
+

+ Durable run{' '} + + {workflowRunId} + + {workflowActions + ? ` · ${workflowActions.actionCount} action(s) · ${workflowActions.transcriptChars} transcript chars` + : actionsBusy + ? ' · running…' + : null} +

+ {workflowActions && workflowActions.actions.length > 0 ? ( +
    + {workflowActions.actions.slice(0, 8).map((a, i) => ( +
  • + {a.tool} + · {a.status} + {a.result ? ( +

    {a.result}

    + ) : null} +
  • + ))} +
+ ) : null} +
+ ) : null}
diff --git a/apps/web/src/lib/__tests__/studio-workflow.test.ts b/apps/web/src/lib/__tests__/studio-workflow.test.ts new file mode 100644 index 000000000..5d3cdeee2 --- /dev/null +++ b/apps/web/src/lib/__tests__/studio-workflow.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getVideoToActionsStatus, + pollVideoToActions, + startVideoToActions, +} from '@/lib/studio-workflow'; + +describe('studio-workflow (WDK Product v1)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('startVideoToActions parses runId and statusUrl', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + runId: 'wrun_abc', + message: 'started', + }), + }), + ); + + const result = await startVideoToActions({ + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + videoTitle: 'Demo', + }); + expect(result.ok).toBe(true); + expect(result.runId).toBe('wrun_abc'); + expect(fetch).toHaveBeenCalledWith( + '/api/workflows/video-to-actions', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('startVideoToActions fails closed when ok but no runId', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + }), + ); + const result = await startVideoToActions({ + url: 'https://www.youtube.com/watch?v=x', + }); + expect(result.ok).toBe(false); + }); + + it('getVideoToActionsStatus maps completed result', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + runId: 'wrun_1', + runStatus: 'completed', + result: { + url: 'https://youtu.be/x', + transcriptChars: 120, + actionCount: 1, + provider: 'openai', + actions: [{ tool: 'create_workflow_task', status: 'fulfilled', result: 'ok' }], + }, + }), + }), + ); + + const poll = await getVideoToActionsStatus('wrun_1'); + expect(poll.ok).toBe(true); + expect(poll.runStatus).toBe('completed'); + expect(poll.result?.actionCount).toBe(1); + expect(poll.result?.actions[0].tool).toBe('create_workflow_task'); + }); + + it('pollVideoToActions returns when status becomes terminal', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true, runId: 'wrun_2', runStatus: 'running' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + runId: 'wrun_2', + runStatus: 'completed', + result: { + url: 'https://youtu.be/x', + transcriptChars: 50, + actionCount: 0, + actions: [], + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const poll = await pollVideoToActions('wrun_2', { attempts: 5, delayMs: 1 }); + expect(poll.runStatus).toBe('completed'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/studio-workflow.ts b/apps/web/src/lib/studio-workflow.ts new file mode 100644 index 000000000..d03eb9424 --- /dev/null +++ b/apps/web/src/lib/studio-workflow.ts @@ -0,0 +1,169 @@ +/** + * Studio helpers for the durable video → transcript → actions workflow (WDK Product v1). + * + * Complements `studio-deploy.ts` (FastAPI /api/pipeline job path). This path uses + * Workflow DevKit: start returns a runId immediately; poll until terminal status. + */ + +export interface VideoToActionsStart { + ok: boolean; + status: number; + runId?: string; + message?: string; + error?: string; +} + +export type WorkflowRunStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | string; + +export interface VideoToActionsAction { + tool: string; + status: string; + result?: string; +} + +export interface VideoToActionsResult { + url: string; + transcriptChars: number; + actionCount: number; + provider?: string; + actions: VideoToActionsAction[]; +} + +export interface VideoToActionsPoll { + ok: boolean; + status: number; + runId: string; + runStatus?: WorkflowRunStatus; + result?: VideoToActionsResult; + error?: string; + message?: string; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() ? v : undefined; +} + +/** Start durable video → actions. Returns immediately with runId. */ +export async function startVideoToActions(input: { + url: string; + videoTitle?: string; + signal?: AbortSignal; +}): Promise { + const response = await fetch('/api/workflows/video-to-actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: input.url, + videoTitle: input.videoTitle, + }), + signal: input.signal ?? AbortSignal.timeout(30_000), + }); + + const payload = (await response.json().catch(() => ({}))) as Record; + const runId = str(payload.runId); + const error = str(payload.error); + const message = str(payload.message); + + return { + ok: Boolean(payload.ok) && response.ok && Boolean(runId), + status: response.status, + runId, + message, + error, + }; +} + +/** Single status poll for a workflow run. */ +export async function getVideoToActionsStatus( + runId: string, + opts?: { signal?: AbortSignal }, +): Promise { + const response = await fetch( + `/api/workflows/video-to-actions/${encodeURIComponent(runId)}`, + { + method: 'GET', + signal: opts?.signal ?? AbortSignal.timeout(15_000), + }, + ); + + const payload = (await response.json().catch(() => ({}))) as Record; + const runStatus = str(payload.runStatus) as WorkflowRunStatus | undefined; + const error = str(payload.error); + const message = str(payload.message); + + let result: VideoToActionsResult | undefined; + if (payload.result && typeof payload.result === 'object') { + const r = payload.result as Record; + const actionsRaw = Array.isArray(r.actions) ? r.actions : []; + result = { + url: str(r.url) || '', + transcriptChars: typeof r.transcriptChars === 'number' ? r.transcriptChars : 0, + actionCount: typeof r.actionCount === 'number' ? r.actionCount : actionsRaw.length, + provider: str(r.provider), + actions: actionsRaw + .filter((a): a is Record => Boolean(a) && typeof a === 'object') + .map((a) => ({ + tool: str(a.tool) || 'unknown', + status: str(a.status) || 'unknown', + result: str(a.result), + })), + }; + } + + return { + ok: response.ok && !error, + status: response.status, + runId: str(payload.runId) || runId, + runStatus, + result, + error, + message, + }; +} + +const TERMINAL = new Set(['completed', 'failed', 'cancelled']); + +/** + * Poll until the run is terminal or attempts are exhausted. + * Default: 20 attempts × 1.5s ≈ 30s of wall time (workflow continues server-side). + */ +export async function pollVideoToActions( + runId: string, + opts?: { attempts?: number; delayMs?: number; signal?: AbortSignal }, +): Promise { + const attempts = opts?.attempts ?? 20; + const delayMs = opts?.delayMs ?? 1500; + let last: VideoToActionsPoll = { + ok: false, + status: 0, + runId, + message: 'No poll yet', + }; + + for (let i = 0; i < attempts; i++) { + if (opts?.signal?.aborted) { + return { ...last, error: last.error || 'aborted', message: 'Polling aborted' }; + } + last = await getVideoToActionsStatus(runId, { signal: opts?.signal }); + if (last.runStatus && TERMINAL.has(last.runStatus)) { + return last; + } + if (last.status === 404) { + return last; + } + await new Promise((r) => setTimeout(r, delayMs)); + } + + return { + ...last, + message: + last.message || + `Still ${last.runStatus || 'running'} after ${attempts} polls — re-check later with runId ${runId}`, + }; +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 97e390d41..da97c6914 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -38,6 +38,7 @@ const AI_ROUTE_PREFIXES = [ '/api/training', '/api/transcribe', '/api/video', + '/api/workflows', ]; // Login gating (activate-when-configured) + server-to-server bypass. diff --git a/apps/web/src/workflows/video-to-actions.ts b/apps/web/src/workflows/video-to-actions.ts index 3de6cf782..6e4a20173 100644 --- a/apps/web/src/workflows/video-to-actions.ts +++ b/apps/web/src/workflows/video-to-actions.ts @@ -1,11 +1,15 @@ /** - * Durable video → actions pipeline (Workflow DevKit). + * Durable video → actions pipeline (Workflow DevKit) — Product v1. * * Orchestrates existing EventRelay capabilities as retryable steps so a long * analysis does not die with a single serverless request timeout. * * Trigger: POST /api/workflows/video-to-actions { url, videoTitle? } + * Status: GET /api/workflows/video-to-actions/:runId * Inspect: npx workflow web (from apps/web) + * + * Steps call server libs directly (no self-HTTP loopback) so Vercel production + * does not depend on NEXTAUTH_URL / internal tokens for step execution. */ import { FatalError } from 'workflow'; @@ -28,9 +32,14 @@ export async function videoToActionsWorkflow( ): Promise { 'use workflow'; - console.log('[video-to-actions] start', { url: input.url }); + const url = (input.url || '').trim(); + if (!url || !/^https?:\/\//i.test(url)) { + throw new FatalError('url must be an http(s) URL'); + } + + console.log('[video-to-actions] start', { url }); - const transcript = await transcribeStep(input.url); + const transcript = await transcribeStep(url); if (!transcript || transcript.trim().length < 40) { throw new FatalError('Transcript too short or unavailable for action extraction'); } @@ -43,7 +52,7 @@ export async function videoToActionsWorkflow( }); return { - url: input.url, + url, transcriptChars: transcript.length, actionCount: agent.actions.length, provider: agent.provider, @@ -60,35 +69,23 @@ async function transcribeStep(url: string): Promise { console.log('[video-to-actions] step:transcribe', url); - // Prefer server-side transcription helper when available; fall back to local API. - try { - const { fetchTranscript } = await import('@/lib/transcription-service'); - const result = await fetchTranscript({ url }); - if (result?.transcript && typeof result.transcript === 'string') { - return result.transcript; - } - } catch (err) { - console.warn('[video-to-actions] transcription-service failed', err); + const { fetchTranscript } = await import('@/lib/transcription-service'); + const result = await fetchTranscript({ url }); + + if (result?.success && result.transcript && typeof result.transcript === 'string') { + return result.transcript; } - const base = - process.env.NEXTAUTH_URL || - process.env.VERCEL_URL?.replace(/^/, 'https://') || - 'http://127.0.0.1:3000'; - const res = await fetch(`${base.replace(/\/$/, '')}/api/transcribe`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }), - }); - const body = (await res.json().catch(() => ({}))) as { - success?: boolean; - transcript?: string; - error?: string; - }; - if (!res.ok || !body.transcript) { - throw new Error(body.error || `Transcribe failed (${res.status})`); + // Retryable when the service is flaky; Fatal when clearly unusable input/config. + const errMsg = + (result && typeof result.error === 'string' && result.error) || + 'Transcription returned no transcript'; + if ( + /invalid|required|rejected|too short|billing|not configured|No AI API/i.test(errMsg) + ) { + throw new FatalError(errMsg); } - return body.transcript; + throw new Error(errMsg); } async function runActionsStep( @@ -114,9 +111,12 @@ async function runActionsStep( })), }; } catch (err) { - // Surface as retryable unless it's clearly a config/client error. const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('No AI API key') || msg.includes('too short')) { + if ( + msg.includes('No AI API key') || + msg.includes('too short') || + /billing|not configured/i.test(msg) + ) { throw new FatalError(msg); } throw err; diff --git a/docs/WORKFLOW_DEVKIT.md b/docs/WORKFLOW_DEVKIT.md index 2505d1629..985371a9f 100644 --- a/docs/WORKFLOW_DEVKIT.md +++ b/docs/WORKFLOW_DEVKIT.md @@ -1,7 +1,7 @@ # Workflow DevKit (Vercel) in EventRelay -**Status:** scaffolded 2026-08-07 -**Package:** `workflow` in the monorepo (workspace root install) +**Status:** Product v1 (2026-08-07) — scaffold + Studio “Act on findings” +**Package:** `workflow` (monorepo root / apps/web) **Next integration:** `withWorkflow` in `apps/web/next.config.js` ## What it is @@ -10,40 +10,56 @@ - `"use workflow"` — orchestrator (sandboxed) - `"use step"` — retryable Node units (full npm access) -- `start()` from `workflow/api` — fire from route handlers +- `start()` / `getRun()` from `workflow/api` — fire and poll from route handlers -## First product workflow +**Product pipeline today still uses** FastAPI agents, `POST /api/pipeline`, SSE (`/api/pipeline/stream`), and Cloud Tasks for full Studio deploy / Dashboard analysis. WDK sits **alongside** that path for long transcript → action runs that should survive serverless timeouts. + +## Product surface (v1) | Piece | Path | |-------|------| | Workflow | `apps/web/src/workflows/video-to-actions.ts` | -| Trigger | `POST /api/workflows/video-to-actions` `{ "url": "https://…", "videoTitle": "…" }` | +| Start | `POST /api/workflows/video-to-actions` `{ "url", "videoTitle?" }` → `{ runId, statusUrl }` | +| Status | `GET /api/workflows/video-to-actions/:runId` → `{ runStatus, result? }` | +| Client helpers | `apps/web/src/lib/studio-workflow.ts` | +| Studio UI | **Act on findings** button in `VideoWorkflowStudio` | -Steps: **transcribe** → **run action agent** (existing `action-agent` / tools). +Steps: **transcribe** (`fetchTranscript`) → **run action agent** (`runActionAgent`). No self-HTTP loopback. ## Local usage ```bash cd apps/web npm run dev -# another terminal: +# start curl -X POST http://localhost:3000/api/workflows/video-to-actions \ -H 'Content-Type: application/json' \ --json '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' +# poll (use runId from response) +curl http://localhost:3000/api/workflows/video-to-actions/ npx workflow web npx workflow inspect runs ``` -## Middleware +## Middleware / rate limits -`middleware.ts` only matches `/dashboard` and `/api/*`. Workflow internal paths under `/.well-known/workflow/` are **not** matched, so no extra exclusion is required today. If the matcher becomes a catch-all, exclude `.well-known/workflow/`. +- `middleware.ts` only matches `/dashboard` and `/api/*`. `/.well-known/workflow/` is **not** matched. +- `/api/workflows` is on the AI rate-limit prefix list in `src/proxy.ts`. ## Turbo -`turbo.json` build outputs include generated Workflow routes under `apps/web` so cache hits keep workflow registration. +`turbo.json` build outputs include `apps/web/src/app/.well-known/workflow/**` so cache hits keep workflow registration. ## Next product steps -1. Wire Studio Deploy to `start(videoToActionsWorkflow)` or a dedicated deploy workflow. -2. Stream step progress via `getWritable()` into Studio UI. -3. Optional human approval hooks before deploy. +1. **C — Studio deploy durable:** kickoff + poll `/api/pipeline` jobs as WDK steps with automatic retry. +2. Stream step progress via `getWritable()` into Studio. +3. Human approval hooks (`createHook` / `resumeHook`) before deploy. +4. Optionally route Dashboard “act” through the same durable path. + +## Relation to F-series + +| Item | Note | +|------|------| +| F3 / F5 / F12 | Studio + actions surfaces already on main; this wires **durable** act-on-findings | +| F11 Payload CMS | Still deferred |