diff --git a/.github/workflows/gh-aw-validation.yml b/.github/workflows/gh-aw-validation.yml index 8062fa45c..8f0b98fcc 100644 --- a/.github/workflows/gh-aw-validation.yml +++ b/.github/workflows/gh-aw-validation.yml @@ -62,7 +62,6 @@ jobs: - name: Compile and validate workflows run: | gh aw compile \ - eventrelay-ci-investigator \ canonical-pr-remediator \ focused-coverage-controller \ --validate \ @@ -71,7 +70,6 @@ jobs: - name: Run actionlint, zizmor, and poutine checks run: | gh aw compile \ - eventrelay-ci-investigator \ canonical-pr-remediator \ focused-coverage-controller \ --actionlint \ @@ -82,6 +80,5 @@ jobs: - name: Verify compiled lock files are committed run: | git diff --exit-code -- \ - .github/workflows/eventrelay-ci-investigator.lock.yml \ .github/workflows/canonical-pr-remediator.lock.yml \ .github/workflows/focused-coverage-controller.lock.yml diff --git a/apps/web/src/app/api/pipeline/stream/route.ts b/apps/web/src/app/api/pipeline/stream/route.ts index fbd687f17..704d8bff0 100644 --- a/apps/web/src/app/api/pipeline/stream/route.ts +++ b/apps/web/src/app/api/pipeline/stream/route.ts @@ -145,6 +145,8 @@ function mapBackendResultToAnalysis(result: Record): VideoAnalysisR architectureCode: '', ingestScript: '', e22Snippets: [], + // F3: keep backend plan surface (not only task_board → actions). + project_scaffold: transcriptAction.project_scaffold ?? null, }; } @@ -706,6 +708,8 @@ async function* generateAgentEvents( transcript: analysis.transcript, architectureCode: analysis.architectureCode, workflow, + // F3: plumb TranscriptActionAgent scaffold into dashboard insights. + project_scaffold: analysis.project_scaffold ?? null, }, timestamp: new Date().toISOString(), }); diff --git a/apps/web/src/components/VideoWorkflowStudio.tsx b/apps/web/src/components/VideoWorkflowStudio.tsx index bb59ef948..322fb709b 100644 --- a/apps/web/src/components/VideoWorkflowStudio.tsx +++ b/apps/web/src/components/VideoWorkflowStudio.tsx @@ -30,6 +30,7 @@ import { type StudioPipelineCheck, type StudioRunQuality, } from '@/lib/studio-pipeline-status'; +import { kickoffStudioDeploy, pollStudioJob } from '@/lib/studio-deploy'; type OutcomeId = 'app' | 'sop' | 'lesson' | 'research' | 'automation' | 'content'; type RunState = 'idle' | 'working' | 'ready'; @@ -366,6 +367,12 @@ export default function VideoWorkflowStudio() { const [saveCount, setSaveCount] = useState(0); const [actionMessage, setActionMessage] = useState('Build a result to unlock preview, export, deploy, and save.'); const [runQuality, setRunQuality] = useState('idle'); + /** Last pipeline kickoff from Run (job id reused by Deploy). */ + const [lastPipelineCheck, setLastPipelineCheck] = useState(null); + const [deployBusy, setDeployBusy] = useState(false); + const [deployJobId, setDeployJobId] = useState(null); + const [deployLiveUrl, setDeployLiveUrl] = useState(null); + const [deployRepo, setDeployRepo] = useState(null); const audioRef = useRef(null); const timerRef = useRef | null>(null); @@ -464,6 +471,9 @@ export default function VideoWorkflowStudio() { } } + setLastPipelineCheck(pipelineCheck); + if (pipelineCheck?.jobId) setDeployJobId(pipelineCheck.jobId); + const quality = studioRunQuality(pipelineCheck, unsafe, Boolean(currentVideoId)); timerRef.current = setTimeout(() => { @@ -487,6 +497,86 @@ export default function VideoWorkflowStudio() { }, unsafe ? 250 : 100); }; + const handleDeploy = async () => { + if (deployBusy) return; + const currentVideoUrl = videoUrlRef.current || videoUrl; + const currentVideoId = getYouTubeId(currentVideoUrl); + if (!currentVideoId) { + setActionMessage('Add a valid YouTube URL before deploying.'); + return; + } + + setDeployBusy(true); + setActionMessage('Starting deploy handoff via /api/pipeline…'); + + try { + // Prefer reusing job from the last run when present. + let jobId = lastPipelineCheck?.jobId || deployJobId || undefined; + + if (!jobId) { + const kick = await kickoffStudioDeploy({ + url: currentVideoUrl, + projectType: selectedOutcome === 'app' ? 'web' : selectedOutcome, + outcome: selectedOutcome, + prompt: promptRef.current || prompt, + }); + jobId = kick.jobId; + if (kick.jobId) setDeployJobId(kick.jobId); + if (kick.live_url) setDeployLiveUrl(kick.live_url); + if (kick.github_repo) setDeployRepo(kick.github_repo); + + if (!kick.ok && !kick.jobId) { + setActionMessage( + kick.message + ? `Deploy handoff blocked: ${kick.message}. Export the package for manual Vercel deploy, or set BACKEND_URL.` + : 'Deploy handoff prepared offline. Set BACKEND_URL for automatic pipeline deployment, or use Export.', + ); + return; + } + + if (kick.live_url) { + setActionMessage(`Deploy live: ${kick.live_url}`); + return; + } + + if (!jobId) { + setActionMessage( + kick.handoff + ? 'Backend accepted a planning handoff (no job id). Use Export for Vercel files, or open Dashboard for full pipeline.' + : 'Deploy kickoff returned no job id. Check BACKEND_URL and pipeline health.', + ); + return; + } + + setActionMessage(`Deploy job started (${jobId}). Polling status…`); + } else { + setActionMessage(`Polling existing job ${jobId}…`); + } + + const polled = await pollStudioJob(jobId, { attempts: 6, delayMs: 1500 }); + if (polled.live_url) setDeployLiveUrl(polled.live_url); + if (polled.github_repo) setDeployRepo(polled.github_repo); + + if (polled.live_url) { + setActionMessage(`Deploy ready: ${polled.live_url}`); + } else if (polled.jobStatus === 'failed' || polled.jobStatus === 'error') { + setActionMessage( + `Deploy job ${jobId} failed${polled.message ? `: ${polled.message}` : ''}. Export package for manual handoff.`, + ); + } else { + setActionMessage( + `Deploy job ${jobId} status: ${polled.jobStatus || 'pending'}. Open Dashboard for live analysis, or Export for offline Vercel handoff.`, + ); + } + } catch (err) { + setActionMessage( + `Deploy request failed: ${err instanceof Error ? err.message : String(err)}. Export still works offline.`, + ); + } finally { + setDeployBusy(false); + } + }; + const handleResultAction = (action: ResultAction) => { setActiveAction(action); if (!resultReady) { @@ -511,11 +601,12 @@ export default function VideoWorkflowStudio() { return; } - setActionMessage( - action === 'deploy' - ? 'Deploy handoff prepared. Connect the backend pipeline when BACKEND_URL is healthy for automatic deployment.' - : 'Preview is open with source notes, deliverables, and next steps.', - ); + if (action === 'deploy') { + void handleDeploy(); + return; + } + + setActionMessage('Preview is open with source notes, deliverables, and next steps.'); }; return ( @@ -843,8 +934,50 @@ export default function VideoWorkflowStudio() { {activeAction === 'deploy' && (

- This is deployable as a Vercel handoff now. Automatic backend deployment is gated by the configured backend pipeline health. + Deploy calls POST /api/pipeline with{' '} + deployment_target=vercel + {deployBusy ? ' (in progress…)' : '.'} If the backend is down, use Export for an offline handoff.

+ {(deployJobId || deployLiveUrl || deployRepo) && ( +
+ {deployJobId && ( +
+ Job:{' '} + + {deployJobId} + +
+ )} + {deployLiveUrl && ( +
+ Live:{' '} + + {deployLiveUrl} + +
+ )} + {deployRepo && ( +
+ Repo:{' '} + + {deployRepo} + +
+ )} +
+ )} +
{generatedPackage.nextSteps.map((step) => (
diff --git a/apps/web/src/components/dashboard/panels.tsx b/apps/web/src/components/dashboard/panels.tsx index 9776abd1e..892915a98 100644 --- a/apps/web/src/components/dashboard/panels.tsx +++ b/apps/web/src/components/dashboard/panels.tsx @@ -4,7 +4,14 @@ import { Suspense } from 'react'; import dynamic from 'next/dynamic'; import FeedbackWidget from '@/components/FeedbackWidget'; import InteractiveTranscript, { type TranscriptSegment } from '@/components/InteractiveTranscript'; +import { + buildScaffoldPackage, + downloadScaffoldPackage, + summarizeProjectScaffold, + type ActionCardLike, +} from '@/lib/action-surface'; import { hasRichDashboardInsights, isThinDashboardAnalysis } from '@/lib/dashboard-analysis'; +import { useActionAgentStore } from '@/store/action-agent-store'; import type { SearchResult, Video } from '@/store/dashboard-types'; const TranscriptViewer = dynamic(() => import('@/components/TranscriptViewer'), { @@ -186,8 +193,199 @@ export function ActionsPanel({ video: Video; onExtractEvents?: (videoId: string) => void; }) { + const transcript = (video.transcript || '').trim(); + const hasTranscript = transcript.length > 40; + const { lifecycle, isRunning, runFromTranscript, reset } = useActionAgentStore(); + const fulfilled = lifecycle.actions || []; + const plannedActions = video.insights?.actions || []; + const projectScaffold = video.insights?.project_scaffold; + const scaffoldPreview = summarizeProjectScaffold(projectScaffold); + + const exportScaffold = () => { + // Prefer tool-fulfilled titles; fall back to planned analysis actions. + const fromTools: ActionCardLike[] = fulfilled + .filter((a) => typeof a.input?.title === 'string' || a.tool) + .map((a) => ({ + title: + typeof a.input?.title === 'string' + ? a.input.title + : a.tool.replace(/_/g, ' '), + description: + a.result || + (typeof a.input?.description === 'string' ? a.input.description : ''), + category: a.tool, + })); + const fromPlan: ActionCardLike[] = plannedActions.map((a) => ({ + title: a.title, + description: a.description, + category: a.category, + estimatedMinutes: a.estimatedMinutes, + })); + const actions = fromTools.length > 0 ? fromTools : fromPlan; + const pkg = buildScaffoldPackage({ + projectName: video.title || 'eventrelay-project', + actions, + projectScaffold, + }); + downloadScaffoldPackage(pkg); + }; + + const canExport = + fulfilled.length > 0 || plannedActions.length > 0 || projectScaffold != null; + return (
+ {/* F12: Act on findings via /api/agents/actions */} +
+
+
+

+ Act on findings +

+

+ Canonical action surface (F3): run tools via{' '} + /api/agents/actions, then export a scaffold package. +

+
+
+ {fulfilled.length > 0 && ( + + )} + +
+
+ + {!hasTranscript && ( +

+ Need a transcript on this video before the action agent can run. +

+ )} + + {lifecycle.phase !== 'idle' && ( +

+ Phase: {lifecycle.phase} + {lifecycle.provider ? ` · ${lifecycle.provider}` : ''} + {lifecycle.error ? ` · ${lifecycle.error}` : ''} +

+ )} + + {fulfilled.length > 0 && ( +
    + {fulfilled.map((action, i) => ( +
  • +
    + + {action.tool} + + + {action.status} + +
    + {action.result && ( +

    + {action.result} +

    + )} + {typeof action.input?.title === 'string' && ( +

    + {action.input.title} +

    + )} +
  • + ))} +
+ )} +
+ + {/* F3: Plan surface — TranscriptActionAgent project_scaffold + package export */} +
+
+
+

+ Project scaffold +

+

+ Plan from analysis (project_scaffold) plus deterministic + package files (README, tasks.json) for offline handoff. +

+
+ +
+ + {scaffoldPreview.length > 0 ? ( +
    + {scaffoldPreview.map((line) => ( +
  • + {line} +
  • + ))} +
+ ) : ( +

+ {plannedActions.length > 0 + ? `${plannedActions.length} planned action(s) from analysis — export builds tasks.json without a Gemini scaffold blob.` + : 'Re-analyze with the backend transcript-action path to populate project_scaffold, or Act on findings then export.'} +

+ )} +
+ }> { + it('buildScaffoldPackage emits README, tasks.json, and stub index', () => { + const pkg = buildScaffoldPackage({ + projectName: 'My Cool App!', + actions: [ + { title: 'Wire auth', description: 'Add OAuth', category: 'setup', estimatedMinutes: 30 }, + { title: 'Deploy', category: 'deploy', start: 10, end: 45, confidence: 0.9 }, + ], + }); + + expect(pkg.projectName).toBe('my-cool-app'); + expect(pkg.files['README.md']).toContain('# my-cool-app'); + expect(pkg.files['README.md']).toContain('TASK-001: Wire auth'); + expect(pkg.files['tasks.json']).toContain('Wire auth'); + expect(pkg.files['src/index.ts']).toContain('Generated project scaffold'); + expect(pkg.files['project_scaffold.json']).toBeUndefined(); + }); + + it('includes project_scaffold.json when Gemini scaffold is present', () => { + const scaffold = { + repository_structure: [{ path: 'src/app.ts', purpose: 'entry' }], + core_modules: [{ name: 'api', responsibility: 'HTTP' }], + }; + const pkg = buildScaffoldPackage({ + projectName: 'demo', + actions: [{ title: 'Ship it' }], + projectScaffold: scaffold, + }); + expect(pkg.files['project_scaffold.json']).toContain('src/app.ts'); + }); + + it('summarizeProjectScaffold extracts structure and modules', () => { + const lines = summarizeProjectScaffold({ + repository_structure: [{ path: 'lib/x.ts', purpose: 'core' }], + core_modules: [{ name: 'worker', responsibility: 'jobs' }], + }); + expect(lines.some((l) => l.includes('lib/x.ts'))).toBe(true); + expect(lines.some((l) => l.includes('worker'))).toBe(true); + }); + + it('summarizeProjectScaffold handles raw string fallback', () => { + expect(summarizeProjectScaffold({ raw: 'plain scaffold text' })).toEqual([ + 'plain scaffold text', + ]); + }); +}); diff --git a/apps/web/src/lib/__tests__/studio-deploy.test.ts b/apps/web/src/lib/__tests__/studio-deploy.test.ts new file mode 100644 index 000000000..6c8bf790b --- /dev/null +++ b/apps/web/src/lib/__tests__/studio-deploy.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { kickoffStudioDeploy, pollStudioJob } from '@/lib/studio-deploy'; + +describe('studio-deploy (F5)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('kickoffStudioDeploy parses job_id from pipeline response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 202, + json: async () => ({ + job_id: 'job_abc', + status_url: '/api/jobs/job_abc', + pipeline: 'backend-async', + }), + }), + ); + + const result = await kickoffStudioDeploy({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }); + expect(result.ok).toBe(true); + expect(result.jobId).toBe('job_abc'); + expect(result.statusUrl).toBe('/api/jobs/job_abc'); + expect(result.handoff).toBe(false); + expect(fetch).toHaveBeenCalledWith( + '/api/pipeline', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('kickoffStudioDeploy marks handoff when no job and not ok', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ error: 'BACKEND_URL not configured' }), + }), + ); + + const result = await kickoffStudioDeploy({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }); + expect(result.ok).toBe(false); + expect(result.handoff).toBe(true); + expect(result.message).toContain('BACKEND_URL'); + }); + + it('pollStudioJob returns live_url when job completes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + status: 'completed', + data: { status: 'completed', live_url: 'https://example.vercel.app' }, + }), + }), + ); + + const polled = await pollStudioJob('job_1', { attempts: 1, delayMs: 0 }); + expect(polled.ok).toBe(true); + expect(polled.live_url).toBe('https://example.vercel.app'); + expect(polled.jobStatus).toBe('completed'); + }); +}); diff --git a/apps/web/src/lib/action-surface.ts b/apps/web/src/lib/action-surface.ts new file mode 100644 index 000000000..992152134 --- /dev/null +++ b/apps/web/src/lib/action-surface.ts @@ -0,0 +1,155 @@ +/** + * Canonical action surface (F3). + * + * Single product path for "act on findings" inside EventRelay: + * + * 1. Plan — TranscriptActionAgent (backend) emits summary, task_board, + * and project_scaffold via /api/video or /api/pipeline/stream. + * 2. Act — Next action-agent (apps/web) runs POST /api/agents/actions; + * LLM chooses tools from action-tools.ts and executes them. + * 3. Package — Deterministic scaffold files (README + tasks.json + stub) + * absorbed from video-intelligence-workbench /api/scaffold. + * + * Prototypes outside this tree (workbench /api/actions + /api/scaffold, + * action-genai, youtube-transcript-app ActionExtractor) are non-canonical. + */ + +export interface ActionCardLike { + title: string; + description?: string; + category?: string; + estimatedMinutes?: number | null; + /** Optional timestamp range in seconds (workbench ActionCard). */ + start?: number; + end?: number; + confidence?: number; + tags?: string[]; + snippet?: string; +} + +export interface ScaffoldPackage { + projectName: string; + files: Record; +} + +function safeProjectName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, '-') + .replace(/^-+|-+$/g, '') || 'generated-project' + ); +} + +/** Build deterministic scaffold files from planned/fulfilled actions (workbench absorb). */ +export function buildScaffoldPackage(input: { + projectName?: string; + actions: ActionCardLike[]; + /** Optional Gemini project_scaffold blob from TranscriptActionAgent. */ + projectScaffold?: unknown; +}): ScaffoldPackage { + const name = safeProjectName(input.projectName || 'generated-project'); + const tasks = input.actions.map((a, i) => ({ + id: `TASK-${String(i + 1).padStart(3, '0')}`, + title: a.title, + description: a.description || '', + category: a.category || 'build', + estimatedMinutes: a.estimatedMinutes ?? null, + source: + typeof a.start === 'number' && typeof a.end === 'number' + ? `${Math.round(a.start)}s-${Math.round(a.end)}s` + : undefined, + confidence: a.confidence, + tags: a.tags, + snippet: a.snippet, + })); + + const taskLines = tasks + .map((t) => { + const meta = [t.source, t.category].filter(Boolean).join(', '); + return `- [ ] ${t.id}: ${t.title}${meta ? ` (${meta})` : ''}`; + }) + .join('\n'); + + const files: Record = { + 'README.md': `# ${name}\n\nGenerated from EventRelay video action surface.\n\n## Tasks\n\n${ + taskLines || '- (no tasks yet — run Act on findings or re-analyze the video)' + }\n`, + 'tasks.json': JSON.stringify(tasks, null, 2) + '\n', + 'src/index.ts': + "export function main() {\n console.log('Generated project scaffold loaded.');\n}\n\nmain();\n", + }; + + if (input.projectScaffold != null) { + files['project_scaffold.json'] = + JSON.stringify(input.projectScaffold, null, 2) + '\n'; + } + + return { projectName: name, files }; +} + +/** Human-readable preview lines for a project_scaffold blob. */ +export function summarizeProjectScaffold(scaffold: unknown, maxItems = 6): string[] { + if (scaffold == null) return []; + if (typeof scaffold === 'string') { + const t = scaffold.trim(); + return t ? [t.slice(0, 200)] : []; + } + if (typeof scaffold !== 'object') return [String(scaffold)]; + + const obj = scaffold as Record; + const lines: string[] = []; + + if (typeof obj.raw === 'string' && Object.keys(obj).length === 1) { + return [obj.raw.slice(0, 200)]; + } + + const structure = obj.repository_structure; + if (Array.isArray(structure)) { + for (const item of structure.slice(0, maxItems)) { + if (typeof item === 'string') lines.push(item); + else if (item && typeof item === 'object') { + const row = item as Record; + const path = typeof row.path === 'string' ? row.path : typeof row.name === 'string' ? row.name : null; + const purpose = typeof row.purpose === 'string' ? row.purpose : typeof row.description === 'string' ? row.description : ''; + if (path) lines.push(purpose ? `${path} — ${purpose}` : path); + else lines.push(JSON.stringify(item).slice(0, 120)); + } + } + } + + const modules = obj.core_modules; + if (Array.isArray(modules) && lines.length < maxItems) { + for (const m of modules.slice(0, maxItems - lines.length)) { + if (m && typeof m === 'object') { + const row = m as Record; + const name = typeof row.name === 'string' ? row.name : 'module'; + const resp = typeof row.responsibility === 'string' ? row.responsibility : ''; + lines.push(resp ? `Module ${name}: ${resp}` : `Module ${name}`); + } + } + } + + if (lines.length === 0) { + lines.push(JSON.stringify(obj).slice(0, 200)); + } + return lines.slice(0, maxItems); +} + +/** Trigger browser downloads for each file in a scaffold package. */ +export function downloadScaffoldPackage(pkg: ScaffoldPackage): void { + if (typeof document === 'undefined') return; + const entries = Object.entries(pkg.files); + for (const [path, content] of entries) { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = path.includes('/') ? path.split('/').pop() || path : path; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } +} diff --git a/apps/web/src/lib/gemini-video-analyzer.ts b/apps/web/src/lib/gemini-video-analyzer.ts index cfdb2746f..a66dc7ec5 100644 --- a/apps/web/src/lib/gemini-video-analyzer.ts +++ b/apps/web/src/lib/gemini-video-analyzer.ts @@ -48,6 +48,8 @@ export interface VideoAnalysisResult { code: string; language: string; }[]; + /** Optional TranscriptActionAgent project scaffold (backend path). */ + project_scaffold?: unknown; } /** diff --git a/apps/web/src/lib/studio-deploy.ts b/apps/web/src/lib/studio-deploy.ts new file mode 100644 index 000000000..2e8871609 --- /dev/null +++ b/apps/web/src/lib/studio-deploy.ts @@ -0,0 +1,153 @@ +/** + * Studio deploy handoff helpers (F5). + * Kick off /api/pipeline with deployment_target=vercel and optionally poll job status. + */ + +export interface StudioDeployKickoff { + ok: boolean; + status: number; + jobId?: string; + statusUrl?: string; + pipeline?: string; + live_url?: string | null; + github_repo?: string | null; + message?: string; + /** True when backend only returned a configuration handoff (no live deploy). */ + handoff: boolean; +} + +export interface StudioJobPoll { + ok: boolean; + status: number; + jobStatus?: string; + live_url?: string | null; + github_repo?: string | null; + message?: string; + raw?: unknown; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() ? v : undefined; +} + +export async function kickoffStudioDeploy(input: { + url: string; + projectType?: string; + outcome?: string; + prompt?: string; + signal?: AbortSignal; +}): Promise { + const response = await fetch('/api/pipeline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: input.url, + async: true, + project_type: input.projectType || 'web', + deployment_target: 'vercel', + outcome: input.outcome, + prompt: input.prompt, + }), + signal: input.signal ?? AbortSignal.timeout(30_000), + }); + + const payload = (await response.json().catch(() => ({}))) as Record; + const nested = + payload.result && typeof payload.result === 'object' + ? (payload.result as Record) + : {}; + + const jobId = str(payload.job_id) || str(nested.job_id); + const statusUrl = str(payload.status_url) || str(nested.status_url); + const pipeline = str(payload.pipeline); + const live_url = + (str(nested.live_url) as string | undefined) ?? + (str(payload.live_url) as string | undefined) ?? + null; + const github_repo = + (str(nested.github_repo) as string | undefined) ?? + (str(payload.github_repo) as string | undefined) ?? + null; + const message = + str(payload.error) || + str(payload.detail) || + str(nested.message) || + str(payload.message); + + const handoff = + !response.ok || + pipeline === 'local-fallback' || + pipeline === 'transcript-only' || + (!jobId && !live_url); + + return { + ok: response.ok, + status: response.status, + jobId, + statusUrl, + pipeline, + live_url, + github_repo, + message, + handoff, + }; +} + +/** Poll GET /api/jobs/{jobId} a few times for terminal-ish status. */ +export async function pollStudioJob( + jobId: string, + opts?: { attempts?: number; delayMs?: number; signal?: AbortSignal }, +): Promise { + const attempts = opts?.attempts ?? 6; + const delayMs = opts?.delayMs ?? 1500; + let last: StudioJobPoll = { ok: false, status: 0, message: 'No poll attempts' }; + + for (let i = 0; i < attempts; i++) { + if (opts?.signal?.aborted) { + return { ok: false, status: 0, message: 'Polling aborted' }; + } + try { + const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`, { + cache: 'no-store', + signal: opts?.signal ?? AbortSignal.timeout(15_000), + }); + const body = (await res.json().catch(() => ({}))) as Record; + const data = + body.data && typeof body.data === 'object' + ? (body.data as Record) + : body; + const jobStatus = str(data.status) || str(body.status); + const live_url = str(data.live_url) ?? str(body.live_url) ?? null; + const github_repo = str(data.github_repo) ?? str(body.github_repo) ?? null; + last = { + ok: res.ok, + status: res.status, + jobStatus, + live_url, + github_repo, + message: str(body.error) || str(body.detail) || str(data.message), + raw: body, + }; + + if ( + live_url || + jobStatus === 'completed' || + jobStatus === 'failed' || + jobStatus === 'error' || + jobStatus === 'succeeded' + ) { + return last; + } + } catch (err) { + last = { + ok: false, + status: 0, + message: err instanceof Error ? err.message : String(err), + }; + } + if (i < attempts - 1) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + return last; +} diff --git a/apps/web/src/store/dashboard-store.ts b/apps/web/src/store/dashboard-store.ts index ad7fa5a6a..d26cfc587 100644 --- a/apps/web/src/store/dashboard-store.ts +++ b/apps/web/src/store/dashboard-store.ts @@ -189,6 +189,10 @@ function applyStreamEvent( actions: Array.isArray(data.actions) ? (data.actions as Action[]) : [], sentiment: 'Neutral', topics: Array.isArray(data.topics) ? (data.topics as string[]) : [], + // F3: preserve TranscriptActionAgent project_scaffold from stream. + ...(data.project_scaffold != null + ? { project_scaffold: data.project_scaffold } + : {}), }, ...(events.length > 0 ? { events } : {}), ...(transcript ? { transcript } : {}), @@ -342,6 +346,10 @@ async function legacyAnalyze(url: string, id: string, ctx: StreamCtx & { getVide actions: result.result?.insights?.actions || [], sentiment: result.result?.insights?.sentiment || 'Neutral', topics: result.result?.insights?.topics || [], + // F3: /api/video already returns project_scaffold from transcript_action. + ...(result.result?.insights?.project_scaffold != null + ? { project_scaffold: result.result.insights.project_scaffold } + : {}), }, }); addActivity(`Analysis complete: ${videoTitle.substring(0, 30)}`, 'success'); @@ -575,6 +583,9 @@ export const useDashboardStore = create()( actions: streamed?.insights?.actions ?? [], sentiment: 'Neutral', topics: streamed?.insights?.topics ?? ['partial-analysis'], + ...(streamed?.insights?.project_scaffold != null + ? { project_scaffold: streamed.insights.project_scaffold } + : {}), }, }); addActivity('Analysis enrichment unavailable — showing partial result', 'info'); diff --git a/apps/web/src/store/dashboard-types.ts b/apps/web/src/store/dashboard-types.ts index 36956d91b..553dad739 100644 --- a/apps/web/src/store/dashboard-types.ts +++ b/apps/web/src/store/dashboard-types.ts @@ -49,6 +49,12 @@ export interface Video { actions: Action[]; sentiment: string; topics: string[]; + /** + * Gemini project scaffold from backend TranscriptActionAgent + * (repository_structure, core_modules, integration_points). + * Canonical plan surface for F3; paired with Act on findings tools. + */ + project_scaffold?: unknown; }; } diff --git a/tests/unit/test_gh_aw_workflow_governance.py b/tests/unit/test_gh_aw_workflow_governance.py index 2a7a99e52..a548147d6 100644 --- a/tests/unit/test_gh_aw_workflow_governance.py +++ b/tests/unit/test_gh_aw_workflow_governance.py @@ -103,43 +103,10 @@ def test_focused_coverage_controller_can_read_authoritative_runs() -> None: assert "requires a separate approved GitHub App canary" in source -def test_ci_investigator_requires_dedicated_codex_credential() -> None: - workflow = _load_frontmatter( - ROOT / ".github/workflows/eventrelay-ci-investigator.md" - ) - triggers = workflow.get("on", workflow.get(True)) - assert triggers is not None - credential_gate = next( - step - for step in triggers["steps"] - if step.get("name") == "Require dedicated Codex credential" - ) - - assert credential_gate["id"] == "require_codex_credential" - assert credential_gate["env"]["CODEX_API_KEY"] == "${{ secrets.CODEX_API_KEY }}" - assert "Dedicated CODEX_API_KEY is required" in credential_gate["run"] - assert "OPENAI_API_KEY" not in credential_gate["run"] - - compiled = _load_yaml( - ROOT / ".github/workflows/eventrelay-ci-investigator.lock.yml" - ) - pre_activation_steps = compiled["jobs"]["pre_activation"]["steps"] - activation = compiled["jobs"]["activation"] - agent_steps = compiled["jobs"]["agent"]["steps"] - - compiled_gate = next( - step - for step in pre_activation_steps - if step.get("id") == "require_codex_credential" - ) - assert compiled_gate["name"] == "Require dedicated Codex credential" - assert compiled_gate["env"]["CODEX_API_KEY"] == "${{ secrets.CODEX_API_KEY }}" - assert activation["needs"] == "pre_activation" - assert any(step.get("id") == "validate-secret" for step in activation["steps"]) - assert not any( - step.get("name") == "Require dedicated Codex credential" - for step in agent_steps - ) +def test_ci_investigator_workflow_removed() -> None: + """CI Investigator was retired (noise-only output); sources must stay gone.""" + assert not (ROOT / ".github/workflows/eventrelay-ci-investigator.md").exists() + assert not (ROOT / ".github/workflows/eventrelay-ci-investigator.lock.yml").exists() def test_live_smoke_modules_are_excluded_before_import(monkeypatch) -> None: @@ -202,6 +169,6 @@ def test_gh_aw_validation_pins_runtime_version() -> None: step_scripts = [step.get("run", "") for step in workflow["jobs"]["validate-gh-aw"]["steps"]] combined = "\n".join(step_scripts) assert "gh extension install github/gh-aw --pin v0.82.14" in combined - assert "eventrelay-ci-investigator" in combined + assert "eventrelay-ci-investigator" not in combined assert "canonical-pr-remediator" in combined assert "focused-coverage-controller" in combined