From 722fd85ad120641394dc006d2a16abe384bc2bd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 07:47:03 +0000 Subject: [PATCH] fix: parse studio.deploy workflow return instead of generic HOLD Backend jobs finish as complete; GET was swallowing returnValue. Co-authored-by: Hayden --- .../[runId]/__tests__/route.test.ts | 79 ++++++++++++ .../workflows/studio-deploy/[runId]/route.ts | 70 ++++++----- .../src/lib/__tests__/gate-transition.test.ts | 20 +++ .../lib/__tests__/pipeline-async-job.test.ts | 118 ++++++++++++++++++ .../src/lib/__tests__/studio-deploy.test.ts | 17 +++ .../__tests__/studio-pipeline-status.test.ts | 20 +++ .../src/lib/__tests__/studio-workflow.test.ts | 39 ++++++ apps/web/src/lib/backend-url-gate-PLAN.md | 29 ++++- apps/web/src/lib/pipeline-async-job.ts | 65 +++++++++- apps/web/src/lib/studio-deploy.ts | 1 + apps/web/src/lib/studio-pipeline-status.ts | 4 +- apps/web/src/lib/studio-workflow.ts | 33 ++++- apps/web/src/workflows/studio-deploy.ts | 17 ++- 13 files changed, 472 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/app/api/workflows/studio-deploy/[runId]/__tests__/route.test.ts diff --git a/apps/web/src/app/api/workflows/studio-deploy/[runId]/__tests__/route.test.ts b/apps/web/src/app/api/workflows/studio-deploy/[runId]/__tests__/route.test.ts new file mode 100644 index 000000000..ee7d34c3a --- /dev/null +++ b/apps/web/src/app/api/workflows/studio-deploy/[runId]/__tests__/route.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getRun = vi.fn(); + +vi.mock('workflow/api', () => ({ + getRun: (...args: unknown[]) => getRun(...args), +})); + +function runHandle(overrides: { + exists?: boolean; + status?: string; + returnValue?: Promise; +}) { + return { + exists: Promise.resolve(overrides.exists ?? true), + status: Promise.resolve(overrides.status ?? 'completed'), + workflowName: Promise.resolve('studioDeployWorkflow'), + createdAt: Promise.resolve(new Date('2026-09-12T00:00:00.000Z')), + startedAt: Promise.resolve(new Date('2026-09-12T00:00:00.000Z')), + completedAt: Promise.resolve(new Date('2026-09-12T00:00:01.000Z')), + returnValue: overrides.returnValue ?? Promise.resolve({ kind: 'job' }), + }; +} + +describe('GET /api/workflows/studio-deploy/:runId', () => { + beforeEach(() => { + getRun.mockReset(); + }); + + it('surfaces the failed-run cause instead of a generic unread return value', async () => { + const cause = new Error('Deploy job job_1 still complete'); + const failed = new Error('Workflow run failed'); + Object.assign(failed, { cause }); + const rejected = Promise.reject(failed); + rejected.catch(() => undefined); + getRun.mockReturnValue( + runHandle({ + status: 'failed', + returnValue: rejected, + }), + ); + + const { GET } = await import('../route'); + const res = await GET(new Request('https://uvai.io/api/workflows/studio-deploy/wrun_1'), { + params: Promise.resolve({ runId: 'wrun_1' }), + }); + const json = (await res.json()) as Record; + expect(res.status).toBe(200); + expect(json.runStatus).toBe('failed'); + expect(json.error).toBe('Deploy job job_1 still complete'); + expect(json.error).not.toBe('Failed to read workflow return value'); + }); + + it('returns a completed live result when returnValue is readable', async () => { + getRun.mockReturnValue( + runHandle({ + status: 'completed', + returnValue: Promise.resolve({ + kind: 'live', + live_url: 'https://xy.vercel.app', + jobId: 'job_1', + }), + }), + ); + + const { GET } = await import('../route'); + const res = await GET(new Request('https://uvai.io/api/workflows/studio-deploy/wrun_2'), { + params: Promise.resolve({ runId: 'wrun_2' }), + }); + const json = (await res.json()) as Record; + expect(res.status).toBe(200); + expect(json.error).toBeUndefined(); + expect(json.result).toEqual({ + kind: 'live', + live_url: 'https://xy.vercel.app', + jobId: 'job_1', + }); + }); +}); diff --git a/apps/web/src/app/api/workflows/studio-deploy/[runId]/route.ts b/apps/web/src/app/api/workflows/studio-deploy/[runId]/route.ts index 11709eee7..75c7c0b44 100644 --- a/apps/web/src/app/api/workflows/studio-deploy/[runId]/route.ts +++ b/apps/web/src/app/api/workflows/studio-deploy/[runId]/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from 'next/server'; import { getRun } from 'workflow/api'; +import { workflowReturnErrorMessage } from '@/lib/studio-workflow'; +import { withWorldVercelFetch } from '@/lib/world-vercel-fetch'; import type { StudioDeployResult } from '@/workflows/studio-deploy'; export const runtime = 'nodejs'; @@ -15,43 +17,47 @@ export async function GET( } 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 payload = await withWorldVercelFetch(async () => { + const run = getRun(runId); + const exists = await run.exists; + if (!exists) { + return { + status: 404 as const, + body: { ok: false, runId, error: 'Workflow run not found' }, + }; + } - const [runStatus, workflowName, createdAt, startedAt, completedAt] = - await Promise.all([ - run.status, - run.workflowName.catch(() => undefined), - run.createdAt.then((d) => d.toISOString()).catch(() => undefined), - run.startedAt.then((d) => d?.toISOString()).catch(() => undefined), - run.completedAt.then((d) => d?.toISOString()).catch(() => undefined), - ]); + const [runStatus, workflowName, createdAt, startedAt, completedAt] = + await Promise.all([ + run.status, + run.workflowName.catch(() => undefined), + run.createdAt.then((d) => d.toISOString()).catch(() => undefined), + run.startedAt.then((d) => d?.toISOString()).catch(() => undefined), + run.completedAt.then((d) => d?.toISOString()).catch(() => undefined), + ]); - const payload: Record = { - ok: true, - runId, - runStatus, - workflowName, - createdAt, - startedAt, - completedAt, - }; + const body: Record = { + ok: true, + runId, + runStatus, + workflowName, + createdAt, + startedAt, + completedAt, + }; - if (runStatus === 'completed' || runStatus === 'failed') { - try { - payload.result = await run.returnValue; - } catch { - payload.error = 'Failed to read workflow return value'; + if (runStatus === 'completed' || runStatus === 'failed') { + try { + body.result = await run.returnValue; + } catch (err) { + body.error = workflowReturnErrorMessage(err); + } } - } - return NextResponse.json(payload); + return { status: 200 as const, body }; + }); + + return NextResponse.json(payload.body, { status: payload.status }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (/not found|does not exist/i.test(message)) { diff --git a/apps/web/src/lib/__tests__/gate-transition.test.ts b/apps/web/src/lib/__tests__/gate-transition.test.ts index 5ac924401..2e8c01167 100644 --- a/apps/web/src/lib/__tests__/gate-transition.test.ts +++ b/apps/web/src/lib/__tests__/gate-transition.test.ts @@ -203,6 +203,26 @@ describe('evaluateStudioDeployTransition', () => { expect(view.receiptHash).toMatch(/^[a-f0-9]{64}$/); expect(view.version).toBe(GATE_RECEIPT_VERSION); }); + + it('HOLD when the workflow return is missing a live URL — no Deploy completed claim', () => { + const backendReason = 'Backend job finished with no verified live URL'; + const result = evaluateStudioDeployTransition({ + transitionId: 'wrun_01M2A8RXT1HS8NPW70HV6AA0YV', + runId: 'wrun_01M2A8RXT1HS8NPW70HV6AA0YV', + runStatus: 'completed', + kind: 'job', + backendReason, + authority: { actor: 'anonymous' }, + issuedAt: ISSUED_AT, + }); + expect(result.decision).toBe('HOLD'); + expect(result.reason_code).toBe('GATE_HOLD_MISSING_EVIDENCE'); + expect(result.reason.toLowerCase()).not.toMatch(/deploy completed/); + const view = studioGateReceiptView(result, { backendReason }); + expect(view.reason).toContain(backendReason); + expect(view.reason).not.toMatch(/Failed to read workflow return value/); + expect(view.receiptId).toBe('er:gate:v1:wrun_01M2A8RXT1HS8NPW70HV6AA0YV'); + }); }); describe('studioGateReceiptView', () => { diff --git a/apps/web/src/lib/__tests__/pipeline-async-job.test.ts b/apps/web/src/lib/__tests__/pipeline-async-job.test.ts index f21e7ba2e..8b900db9f 100644 --- a/apps/web/src/lib/__tests__/pipeline-async-job.test.ts +++ b/apps/web/src/lib/__tests__/pipeline-async-job.test.ts @@ -107,6 +107,124 @@ describe('pipeline-async-job (WDK C)', () => { expect(isTerminalJobStatus('running')).toBe(false); }); + it('treats backend JobStatus.complete as terminal (not only completed)', () => { + expect(isTerminalJobStatus('complete')).toBe(true); + expect(isTerminalJobStatus('completed')).toBe(true); + }); + + it('reads live_url from job.metadata.outputs.deployment without inventing one', async () => { + vi.mocked(getBackendConfig).mockReturnValue({ + configured: true, + url: 'https://api.uvai.io', + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { + status: 'complete', + metadata: { + outputs: { deployment: { live_url: 'https://ship.example.app' } }, + }, + }, + }), + }), + ); + const status = await fetchAsyncVideoJob('job_complete'); + expect(status.jobStatus).toBe('complete'); + expect(isTerminalJobStatus(status.jobStatus)).toBe(true); + expect(status.live_url).toBe('https://ship.example.app'); + }); + + it('surfaces backend job.error as the status message', async () => { + vi.mocked(getBackendConfig).mockReturnValue({ + configured: true, + url: 'https://api.uvai.io', + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { status: 'failed', error: 'Transcript-action workflow failed' }, + }), + }), + ); + const status = await fetchAsyncVideoJob('job_err'); + expect(status.jobStatus).toBe('failed'); + expect(status.message).toBe('Transcript-action workflow failed'); + }); + + it('passes through a video-to-software live_url from kickoff', async () => { + vi.mocked(checkBackendHealth).mockResolvedValue({ + configured: true, + available: true, + host: 'api.uvai.io', + }); + vi.mocked(getBackendConfig).mockReturnValue({ + configured: true, + url: 'https://api.uvai.io', + }); + const fetchMock = vi.fn().mockImplementation(async (input: unknown) => { + const href = String(input); + if (href.includes('/video-to-software')) { + return { + ok: true, + status: 200, + json: async () => ({ live_url: 'https://xy.vercel.app' }), + }; + } + throw new Error(`unexpected fetch ${href}`); + }); + vi.stubGlobal('fetch', fetchMock); + const kicked = await kickoffAsyncVideoJob( + 'https://www.youtube.com/watch?v=auJzb1D-fag', + ); + expect(kicked.kind).toBe('live'); + expect(kicked.live_url).toBe('https://xy.vercel.app'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.uvai.io/api/v1/video-to-software', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('falls through to videos/process when video-to-software is 401 (not an auth cut)', async () => { + vi.mocked(checkBackendHealth).mockResolvedValue({ + configured: true, + available: true, + host: 'api.uvai.io', + }); + vi.mocked(getBackendConfig).mockReturnValue({ + configured: true, + url: 'https://api.uvai.io', + }); + const fetchMock = vi.fn().mockImplementation(async (input: unknown) => { + const href = String(input); + if (href.includes('/video-to-software')) { + return { + ok: false, + status: 401, + json: async () => ({ error: 'Authentication required' }), + }; + } + return { + ok: true, + status: 202, + json: async () => ({ data: { job_id: 'job_after_401' } }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + const kicked = await kickoffAsyncVideoJob( + 'https://www.youtube.com/watch?v=auJzb1D-fag', + ); + expect(kicked.kind).toBe('job'); + expect(kicked.jobId).toBe('job_after_401'); + expect(kicked.message ?? '').not.toMatch(/Authentication required/); + }); + it('treats a backend HTTP error as failed, not a config handoff', async () => { vi.mocked(checkBackendHealth).mockResolvedValue({ configured: true, diff --git a/apps/web/src/lib/__tests__/studio-deploy.test.ts b/apps/web/src/lib/__tests__/studio-deploy.test.ts index ccc4b03c7..bc9e01a03 100644 --- a/apps/web/src/lib/__tests__/studio-deploy.test.ts +++ b/apps/web/src/lib/__tests__/studio-deploy.test.ts @@ -66,4 +66,21 @@ describe('studio-deploy (F5)', () => { expect(polled.live_url).toBe('https://example.vercel.app'); expect(polled.jobStatus).toBe('completed'); }); + + it('pollStudioJob treats backend complete as terminal', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { status: 'complete', error: 'no live url on transcript job' }, + }), + }), + ); + const polled = await pollStudioJob('job_complete', { attempts: 3, delayMs: 0 }); + expect(polled.ok).toBe(true); + expect(polled.jobStatus).toBe('complete'); + expect(fetch).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts index 0afe3e3de..eaa6b2cf9 100644 --- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts +++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts @@ -266,6 +266,26 @@ describe('studio-pipeline-status', () => { }); it('does not claim Deploy completed without a verified live receipt', () => { + expect( + studioDeployOutcomeMessage({ + runStatus: 'failed', + error: 'Deploy job job_1 still complete', + }), + ).toBe('Deploy job job_1 still complete'); + expect( + studioDeployOutcomeMessage({ + runStatus: 'completed', + kind: 'job', + message: 'Backend job finished with no verified live URL', + }), + ).toBe('Backend job finished with no verified live URL'); + expect( + studioDeployOutcomeMessage({ + runStatus: 'completed', + kind: 'job', + message: 'Backend job finished with no verified live URL', + }), + ).not.toMatch(/Deploy completed/i); const completedNoUrl = studioDeployOutcomeMessage({ runStatus: 'completed' }); expect(completedNoUrl.toLowerCase()).not.toMatch(/deploy completed/); expect(completedNoUrl.toLowerCase()).not.toMatch(/\bsuccess(?:ful|fully)?\b/); diff --git a/apps/web/src/lib/__tests__/studio-workflow.test.ts b/apps/web/src/lib/__tests__/studio-workflow.test.ts index 029b10334..b5ea065e4 100644 --- a/apps/web/src/lib/__tests__/studio-workflow.test.ts +++ b/apps/web/src/lib/__tests__/studio-workflow.test.ts @@ -5,9 +5,18 @@ import { pollVideoToActions, startStudioDeploy, startVideoToActions, + workflowReturnErrorMessage, } from '@/lib/studio-workflow'; describe('studio-workflow (WDK Product v1)', () => { + it('prefers a failed-run cause over a generic unread-return message', () => { + const cause = new Error('Deploy job job_1 still complete'); + const failed = new Error('Workflow run failed'); + Object.assign(failed, { cause }); + expect(workflowReturnErrorMessage(failed)).toBe('Deploy job job_1 still complete'); + expect(workflowReturnErrorMessage(new Error('fetch failed'))).toBe('fetch failed'); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -217,6 +226,36 @@ describe('studio-workflow (WDK Product v1)', () => { expect(started.ok).toBe(false); }); + it('pollStudioDeploy keeps polling when completed has no result yet', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + runId: 'wrun_unread', + runStatus: 'completed', + error: 'Failed to read workflow return value', + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + runId: 'wrun_unread', + runStatus: 'completed', + result: { kind: 'live', live_url: 'https://ready.example.app' }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + const poll = await pollStudioDeploy('wrun_unread', { attempts: 4, delayMs: 1 }); + expect(poll.result?.live_url).toBe('https://ready.example.app'); + expect(poll.error).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('pollStudioDeploy returns immediately on 404', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, diff --git a/apps/web/src/lib/backend-url-gate-PLAN.md b/apps/web/src/lib/backend-url-gate-PLAN.md index 9054a665b..70906a792 100644 --- a/apps/web/src/lib/backend-url-gate-PLAN.md +++ b/apps/web/src/lib/backend-url-gate-PLAN.md @@ -1,4 +1,31 @@ -# TASK: Wire BACKEND_URL so studio.deploy can emit a verified live URL receipt +# TASK: Parse studio.deploy workflow return / live URL (residual after #1848) + +## 1. Goal & Scope +* **Objective:** Attempt deploy must not HOLD solely for `Failed to read workflow return value`. PASS only with a verified https live URL + EventRelay receipt; otherwise honest HOLD/REJECT/ESCALATE with the real reason. +* **Context:** #1848 pinned `BACKEND_URL` to `https://api.uvai.io`. AXIOM re-dogfood XYMcBrFSJ4c then HOLDs with unread workflow return (receipt `er:gate:v1:wrun_01M2A8RXT1HS8NPW70HV6AA0YV`). Not an API-key cut. +* **Root cause:** Backend jobs finish as `JobStatus.complete` (`"complete"`). Studio only treated `"completed"` as terminal, so the WDK poll step retried until the run failed. GET `/api/workflows/studio-deploy/:runId` then swallowed `returnValue` (a `WorkflowRunFailedError` whose cause was the real job message) as `Failed to read workflow return value`. Nested `live_url` / `job.error` were also dropped. `/videos/process` is transcript-only; a real live URL can only come from `/video-to-software`. +* **Scope:** `pipeline-async-job.ts`, `studio-workflow.ts`, `studio-deploy.ts` (F5 poll), `workflows/studio-deploy.ts`, GET `[runId]/route.ts`. + * *Initial check:* Modify existing parsers; do not invent a live URL or weaken G.A.T.E. + +## 2. Execution Plan +- [x] Step 1: Lock failing tests (`complete` terminal, nested live_url, job.error, vts pass-through, unread-return poll, failed-run cause) +- [x] Step 2: Parse backend `complete`; surface returnValue cause; wrap GET in `withWorldVercelFetch`; try video-to-software then fall through +- [x] Step 3: Focused Vitest 85/85 +- [ ] Step 4: Prod Attempt deploy no longer HOLDs solely for unread return value + +## 3. Definition of Done (Success Verification) +* **Expected Outcome:** HOLD reason is a real backend/workflow message or missing-live-URL — not the generic unread-return string. PASS only with a verified https hostname URL. +* **Verification Method:** `cd apps/web && npx vitest run` on the focused files below. +* **Proof Artifact:** 85 passed (pipeline-async-job, studio-workflow, studio-deploy, gate-transition, studio-pipeline-status, pipeline-backend-health, test-env-isolation, studio-deploy POST + GET [runId]) + +## 4. Post-Task Reflection +* **What was done:** Treated backend `complete` as terminal; surfaced WDK `returnValue` cause; wrapped GET in `withWorldVercelFetch`; passed through nested `live_url` / `job.error`; tried `/video-to-software` for a real live URL and fell through on 401. +* **Why it was needed:** After #1848, AXIOM HOLDs with a generic unread-return string because the poller never accepted `complete` and GET swallowed the failed-run cause. +* **How it was tested:** Focused Vitest 85/85. Live `GET /api/v1/health` = 200. Process/vts without key remain 401 (not this cut). Prod Attempt deploy still needs a post-merge READY `dpl_`. + +--- + +# Prior cut: Wire BACKEND_URL so studio.deploy can emit a verified live URL receipt ## 1. Goal & Scope * **Objective:** Studio Attempt deploy on uvai.io / v0-uvai must not HOLD solely because `BACKEND_URL is not configured`. G.A.T.E. can PASS when a real https live URL + EventRelay receipt exist; otherwise honest HOLD/REJECT with a different real reason. diff --git a/apps/web/src/lib/pipeline-async-job.ts b/apps/web/src/lib/pipeline-async-job.ts index 336db8de5..794ad4972 100644 --- a/apps/web/src/lib/pipeline-async-job.ts +++ b/apps/web/src/lib/pipeline-async-job.ts @@ -4,10 +4,12 @@ import { backendHeaders } from '@/lib/pipeline-backend'; import { checkBackendHealth, getBackendConfig } from '@/lib/pipeline-backend-health'; export interface AsyncJobKickoff { - kind: 'job' | 'handoff' | 'failed'; + kind: 'job' | 'handoff' | 'failed' | 'live'; jobId?: string; statusUrl?: string; message?: string; + live_url?: string | null; + github_repo?: string | null; } export interface AsyncJobStatus { @@ -54,6 +56,9 @@ export async function kickoffAsyncVideoJob(url: string): Promise const metadata = asRecord(data.metadata); const outputs = asRecord(metadata?.outputs); + const nestedMeta = asRecord(metadata?.metadata); + const deployment = + asRecord(data.deployment) || + asRecord(outputs?.deployment) || + asRecord(metadata?.deployment); return { ok: response.ok, @@ -120,14 +130,22 @@ export async function fetchAsyncVideoJob(jobId: string): Promise payload.live_url, metadata?.live_url, outputs?.live_url, + deployment?.live_url, + deployment?.url, + nestedMeta?.live_url, ), github_repo: str(data.github_repo) ?? str(payload.github_repo) ?? null, - message: str(payload.error) || str(payload.detail) || str(data.message), + message: + str(payload.error) || + str(payload.detail) || + str(data.error) || + str(data.message), }; } export function isTerminalJobStatus(status: string | undefined): boolean { return ( + status === 'complete' || status === 'completed' || status === 'succeeded' || status === 'failed' || @@ -135,3 +153,46 @@ export function isTerminalJobStatus(status: string | undefined): boolean { status === 'cancelled' ); } + +/** + * Real deploy attempt (FastAPI video-to-software). Pass through a backend + * live URL only — never invent one. 401/403 and other non-live outcomes + * return null so the caller can fall through to the process job. + */ +async function tryVideoToSoftwareDeploy( + backendUrl: string, + url: string, +): Promise { + try { + const response = await fetch(`${backendUrl}/api/v1/video-to-software`, { + method: 'POST', + headers: backendHeaders(), + body: JSON.stringify({ + video_url: url, + project_type: 'web', + deployment_target: 'vercel', + }), + signal: AbortSignal.timeout(50_000), + }); + const payload = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) return null; + const result = asRecord(payload.result); + const deployment = asRecord(payload.deployment) || asRecord(result?.deployment); + const live_url = firstLiveUrl( + payload.live_url, + result?.live_url, + deployment?.live_url, + deployment?.url, + ); + if (!live_url) return null; + return { + kind: 'live', + live_url, + github_repo: str(payload.github_repo) ?? str(result?.github_repo) ?? null, + message: str(payload.message) || str(result?.message), + }; + } catch (err) { + console.error('[pipeline-async-job] video-to-software kickoff failed', err); + return null; + } +} diff --git a/apps/web/src/lib/studio-deploy.ts b/apps/web/src/lib/studio-deploy.ts index 2e8871609..4f787f7e0 100644 --- a/apps/web/src/lib/studio-deploy.ts +++ b/apps/web/src/lib/studio-deploy.ts @@ -131,6 +131,7 @@ export async function pollStudioJob( if ( live_url || + jobStatus === 'complete' || jobStatus === 'completed' || jobStatus === 'failed' || jobStatus === 'error' || diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index 87db998c0..d5ece7501 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -274,8 +274,8 @@ export function studioDeployOutcomeMessage(input: { if (status === 'failed' || status === 'cancelled' || status === 'error') { return `Deploy ${status}. No verified live URL.`; } - const handoff = input.message?.trim(); - if (input.kind === 'handoff' && handoff) return handoff; + const detail = input.message?.trim(); + if ((input.kind === 'handoff' || input.kind === 'job') && detail) return detail; return 'Deploy attempt ended. No verified deploy receipt — UNKNOWN checks are not a live URL.'; } diff --git a/apps/web/src/lib/studio-workflow.ts b/apps/web/src/lib/studio-workflow.ts index 2cfa01909..b303e987a 100644 --- a/apps/web/src/lib/studio-workflow.ts +++ b/apps/web/src/lib/studio-workflow.ts @@ -59,6 +59,35 @@ function str(v: unknown): string | undefined { const TERMINAL = new Set(['completed', 'failed', 'cancelled']); +/** WDK failed-run cause, not a generic unread-return placeholder. */ +export function workflowReturnErrorMessage(err: unknown): string { + if (err && typeof err === 'object') { + const rec = err as Record; + const cause = rec.cause; + if (cause instanceof Error) { + const fromCause = cause.message.trim(); + if (fromCause) return fromCause; + } else if (cause && typeof cause === 'object') { + const fromCause = str((cause as { message?: unknown }).message); + if (fromCause) return fromCause; + } + } + if (err instanceof Error) { + const message = err.message.trim(); + if (message) return message; + } + return 'Workflow run failed'; +} + +export function isUnreadWorkflowReturn(poll: { + runStatus?: string; + result?: unknown; + error?: string; +}): boolean { + if (poll.runStatus !== 'completed' || poll.result) return false; + return /failed to read workflow return value|return value/i.test(poll.error || ''); +} + /** Start durable Studio deploy (WDK C). Returns immediately with runId. */ export interface StudioDeployStart { ok: boolean; @@ -162,7 +191,9 @@ export async function pollStudioDeploy( return { ...last, error: last.error || 'aborted', message: 'Polling aborted' }; } last = await getStudioDeployStatus(runId, { signal: opts?.signal }); - if (last.runStatus && TERMINAL.has(last.runStatus)) return last; + if (last.runStatus && TERMINAL.has(last.runStatus) && !isUnreadWorkflowReturn(last)) { + return last; + } if (last.status === 404) return last; if (i < attempts - 1) { await new Promise((resolve) => { diff --git a/apps/web/src/workflows/studio-deploy.ts b/apps/web/src/workflows/studio-deploy.ts index a7bdcecc6..97c87e274 100644 --- a/apps/web/src/workflows/studio-deploy.ts +++ b/apps/web/src/workflows/studio-deploy.ts @@ -40,6 +40,15 @@ export async function studioDeployWorkflow( if (kicked.kind === 'failed') { throw new FatalError(kicked.message || 'Backend refused the deploy kickoff'); } + if (kicked.kind === 'live' && kicked.live_url) { + return { + url, + kind: 'live', + live_url: kicked.live_url, + github_repo: kicked.github_repo, + message: kicked.message, + }; + } if (kicked.kind !== 'job' || !kicked.jobId) { return { url, @@ -53,9 +62,11 @@ export async function studioDeployWorkflow( } async function kickoffStep(url: string): Promise<{ - kind: 'job' | 'handoff' | 'failed'; + kind: 'job' | 'handoff' | 'failed' | 'live'; jobId?: string; message?: string; + live_url?: string | null; + github_repo?: string | null; }> { 'use step'; @@ -112,6 +123,8 @@ async function pollJobStep(jobId: string): Promise<{ jobStatus: status.jobStatus, live_url: status.live_url, github_repo: status.github_repo, - message: status.message, + message: + status.message || + (status.live_url ? undefined : 'Backend job finished with no verified live URL'), }; }