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
3 changes: 0 additions & 3 deletions .github/workflows/gh-aw-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ jobs:
- name: Compile and validate workflows
run: |
gh aw compile \
eventrelay-ci-investigator \
canonical-pr-remediator \
focused-coverage-controller \
--validate \
Expand All @@ -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 \
Expand All @@ -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
4 changes: 4 additions & 0 deletions apps/web/src/app/api/pipeline/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ function mapBackendResultToAnalysis(result: Record<string, any>): VideoAnalysisR
architectureCode: '',
ingestScript: '',
e22Snippets: [],
// F3: keep backend plan surface (not only task_board → actions).
project_scaffold: transcriptAction.project_scaffold ?? null,
};
}

Expand Down Expand Up @@ -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(),
});
Expand Down
145 changes: 139 additions & 6 deletions apps/web/src/components/VideoWorkflowStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<StudioRunQuality>('idle');
/** Last pipeline kickoff from Run (job id reused by Deploy). */
const [lastPipelineCheck, setLastPipelineCheck] = useState<PipelineCheck | null>(null);
const [deployBusy, setDeployBusy] = useState(false);
const [deployJobId, setDeployJobId] = useState<string | null>(null);
const [deployLiveUrl, setDeployLiveUrl] = useState<string | null>(null);
const [deployRepo, setDeployRepo] = useState<string | null>(null);

const audioRef = useRef<HTMLAudioElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;
Comment on lines +513 to +514
Comment thread
vercel[bot] marked this conversation as resolved.

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) {
Expand All @@ -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 (
Expand Down Expand Up @@ -843,8 +934,50 @@ export default function VideoWorkflowStudio() {
{activeAction === 'deploy' && (
<div className="space-y-3">
<p className="leading-6">
This is deployable as a Vercel handoff now. Automatic backend deployment is gated by the configured backend pipeline health.
Deploy calls <code className="text-xs">POST /api/pipeline</code> with{' '}
<code className="text-xs">deployment_target=vercel</code>
{deployBusy ? ' (in progress…)' : '.'} If the backend is down, use Export for an offline handoff.
</p>
{(deployJobId || deployLiveUrl || deployRepo) && (
<div className="space-y-1 rounded-lg border border-blue-100 bg-blue-50/80 px-3 py-2 text-xs leading-5 text-slate-700">
{deployJobId && (
<div>
Job:{' '}
<Link
href={`/dashboard?video=${encodeURIComponent(videoUrl || '')}`}
className="font-mono text-blue-700 underline"
>
{deployJobId}
</Link>
</div>
)}
{deployLiveUrl && (
<div>
Live:{' '}
<a href={deployLiveUrl} target="_blank" rel="noreferrer" className="text-blue-700 underline">
{deployLiveUrl}
</a>
</div>
)}
{deployRepo && (
<div>
Repo:{' '}
<a href={deployRepo} target="_blank" rel="noreferrer" className="text-blue-700 underline">
{deployRepo}
</a>
</div>
)}
</div>
)}
<button
type="button"
disabled={deployBusy}
onClick={() => void handleDeploy()}
className="inline-flex items-center gap-2 rounded-lg bg-slate-950 px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
>
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
{deployBusy ? 'Deploying…' : 'Run deploy handoff'}
</button>
<div className="grid gap-2">
{generatedPackage.nextSteps.map((step) => (
<div key={step} className="flex gap-2 rounded-lg bg-slate-50 p-2 text-xs leading-5">
Expand Down
Loading
Loading