diff --git a/app/api/builder/agent/route.ts b/app/api/builder/agent/route.ts index ed1ba6b..156fcef 100644 --- a/app/api/builder/agent/route.ts +++ b/app/api/builder/agent/route.ts @@ -34,6 +34,7 @@ import type { ProjectRuntimeAdapter, RuntimeAuditSink, } from "../../../../lib/project-runtime-adapter.ts"; +import type { ProjectV2 } from "../../../../lib/project-v2-types.ts"; import { ServerBuilderAuditSink, SnapshotBuilderProjectRepository, @@ -57,6 +58,7 @@ const requestSchema = z.object({ projectId: z.string().regex(/^[a-z0-9][a-z0-9:._-]{0,127}$/i), prompt: z.string().min(1).max(20_000), mode: z.enum(["build", "edit", "repair"]), + buildRequestId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{7,95}$/).optional(), provider: z.object({ provider: z.enum(["free", "gateway", "openai", "anthropic", "openrouter", "kimi", "custom"]), model: z.string().min(1).max(192).optional(), @@ -64,6 +66,103 @@ const requestSchema = z.object({ }).strict(), }).strict(); +function autoBuildRunId(requestId: string): string { + return `auto:${requestId}`; +} + +function withAutoBuildRun( + project: ProjectV2, + requestId: string, + status: "running" | "succeeded" | "failed" | "stopped", +): ProjectV2 { + const id = autoBuildRunId(requestId); + const prior = project.runs.find((run) => run.id === id); + const now = new Date().toISOString(); + const runs = [ + ...project.runs.filter((run) => run.id !== id), + { + id, + taskId: "build", + projectRevision: project.revision, + status, + runtime: "vercel-sandbox" as const, + startedAt: status === "running" ? now : prior?.startedAt ?? now, + ...(status === "running" + ? {} + : { finishedAt: now, exitCode: status === "succeeded" ? 0 : null }), + logIds: prior?.logIds ?? [], + auditEventIds: prior?.auditEventIds ?? [], + }, + ].slice(-256); + const retainedRunIds = new Set(runs.map((run) => run.id)); + return { + ...project, + runs, + logs: project.logs.filter((log) => retainedRunIds.has(log.runId)), + updatedAt: now, + }; +} + +async function claimAutoBuildRequest(input: { + actorId: string; + project: ProjectV2; + repository: BuilderProjectRepository; + requestId: string; +}): Promise<{ status: "claimed" | "running"; project: ProjectV2 }> { + let project = input.project; + for (let attempt = 0; attempt < 2; attempt += 1) { + const existing = project.runs.find( + (run) => run.id === autoBuildRunId(input.requestId), + ); + if (existing?.status === "running" || existing?.status === "queued") { + return { status: "running", project }; + } + try { + project = await input.repository.saveAuthorized( + input.actorId, + withAutoBuildRun(project, input.requestId, "running"), + project.revision, + ); + return { status: "claimed", project }; + } catch (error) { + const current = await input.repository.loadAuthorized( + input.actorId, + project.id, + ); + if (!current) throw error; + const claimed = current.runs.find( + (run) => run.id === autoBuildRunId(input.requestId), + ); + if (claimed?.status === "running" || claimed?.status === "queued") { + return { status: "running", project: current }; + } + if (attempt === 1) throw error; + project = current; + } + } + return { status: "running", project }; +} + +async function settleAutoBuildRequest(input: { + actorId: string; + projectId: string; + repository: BuilderProjectRepository; + requestId: string; + status: "succeeded" | "failed" | "stopped"; +}): Promise { + const current = await input.repository.loadAuthorized(input.actorId, input.projectId); + if (!current) return null; + const run = current.runs.find( + (item) => item.id === autoBuildRunId(input.requestId), + ); + if (!run || run.status !== "running") return current; + return input.repository.saveAuthorized( + input.actorId, + withAutoBuildRun(current, input.requestId, input.status), + current.revision, + ); +} + const ALL_AGENT_PERMISSIONS = new Set([ "files:read", "files:write", @@ -240,14 +339,42 @@ export async function handleBuilderAgentRequest( 400, ); } + if (parsed.data.buildRequestId && parsed.data.mode !== "build") { + return builderJson( + { + code: "BUILDER_INVALID_REQUEST", + error: "Automatic build request IDs are valid only for initial builds.", + }, + 400, + ); + } const repository = dependencies.repository ?? new SnapshotBuilderProjectRepository(); - const project = await repository.loadAuthorized(actorId, parsed.data.projectId); + let project = await repository.loadAuthorized(actorId, parsed.data.projectId); if (!project) { return builderJson( { code: "BUILDER_PROJECT_NOT_FOUND", error: "Project V2 was not found." }, 404, ); } + if (parsed.data.buildRequestId) { + const claim = await claimAutoBuildRequest({ + actorId, + project, + repository, + requestId: parsed.data.buildRequestId, + }); + project = claim.project; + if (claim.status === "running") { + return builderJson( + { + code: "BUILDER_REQUEST_IN_PROGRESS", + status: "running", + project, + }, + 202, + ); + } + } const audit = dependencies.audit ?? new ServerBuilderAuditSink(); const sandboxRuntime = dependencies.runtime ?? new VercelSandboxRuntimeAdapter({ audit }); @@ -264,7 +391,7 @@ export async function handleBuilderAgentRequest( try { const session = new BuilderAgentSession({ actorId, - requestId: randomUUID(), + requestId: parsed.data.buildRequestId ?? randomUUID(), project, repository, runtime: sandboxRuntime, @@ -282,8 +409,10 @@ export async function handleBuilderAgentRequest( request, parsed.data.provider, ); + const builderInput = { ...parsed.data }; + delete builderInput.buildRequestId; const agentRequest = { - ...parsed.data, + ...builderInput, provider: remembered.selection, approvedTools: [...approvedTools], }; @@ -372,9 +501,21 @@ export async function handleBuilderAgentRequest( ); } } + const settledProject = parsed.data.buildRequestId + ? await settleAutoBuildRequest({ + actorId, + projectId: result.project.id, + repository, + requestId: parsed.data.buildRequestId, + status: result.releaseGate.ok ? "succeeded" : "failed", + }) + : null; + const settledResult = settledProject + ? { ...result, project: settledProject } + : result; return builderJson( { - result, + result: settledResult, ...(intelligence ? { intelligence: { @@ -386,8 +527,19 @@ export async function handleBuilderAgentRequest( } : {}), }, - result.status === "blocked" ? 422 : 200, + settledResult.status === "blocked" ? 422 : 200, ); + } catch (error) { + if (parsed.data.buildRequestId) { + await settleAutoBuildRequest({ + actorId, + projectId: project.id, + repository, + requestId: parsed.data.buildRequestId, + status: request.signal.aborted ? "stopped" : "failed", + }).catch(() => undefined); + } + throw error; } finally { request.signal.removeEventListener("abort", abortForDisconnectedClient); } diff --git a/app/api/builder/cleanup/route.ts b/app/api/builder/cleanup/route.ts index 27c0035..39fb89c 100644 --- a/app/api/builder/cleanup/route.ts +++ b/app/api/builder/cleanup/route.ts @@ -64,7 +64,7 @@ export async function handleBuilderCleanupRequest( const adapter = dependencies.runtime ?? new VercelSandboxRuntimeAdapter(); const result = await adapter.cleanupIdle({ idleBefore: new Date(now.getTime() - minutes * 60_000), - limit: 100, + limit: 50, }); return response({ idleMinutes: minutes, diff --git a/app/styles/project-studio.inspector.css b/app/styles/project-studio.inspector.css index a7aee2f..77b33d0 100644 --- a/app/styles/project-studio.inspector.css +++ b/app/styles/project-studio.inspector.css @@ -126,7 +126,7 @@ .upgrade-card strong { color: #5d469b; font-size: 12px; }.upgrade-card p { color: #786b99; font-size: 12px; line-height: 1.5; }.upgrade-card span { color: #7257bd; font-size: 12px; font-weight: 760; } .file-tree { border: 1px solid #dde6f1; border-radius: 10px; overflow: hidden; } .file-tree span,.file-tree button { align-items: center; background: white; border: 0; border-bottom: 1px solid #edf1f6; color: #52667f; display: grid; font-size: 12px; gap: 7px; grid-template-columns: 15px 1fr auto; padding: 9px; text-align: left; width: 100%; }.file-tree span:last-child { border-bottom: 0; }.file-tree button:hover { background: #f3f7ff; color: #2f62c4; }.file-tree svg { color: #4774cf; height: 12px; width: 12px; }.file-tree b { color: #52617a; font-size: 12px; } -.quality-hero { align-items: center; border: 1px solid; border-radius: 11px; display: grid; gap: 9px; grid-template-columns: 38px 1fr; margin-bottom: 12px; padding: 11px; }.quality-hero > span { align-items: center; border-radius: 10px; display: flex; height: 36px; justify-content: center; }.quality-hero svg { height: 18px; width: 18px; }.quality-hero > div { display: grid; gap: 4px; }.quality-hero strong { font-size: 12px; }.quality-hero small { font-size: 12px; line-height: 1.45; }.quality-hero.passed { background: #eff9f5; border-color: #bde6d5; color: #217558; }.quality-hero.passed > span { background: #dff4eb; }.quality-hero.failed { background: #fff4f2; border-color: #f0c8c2; color: #a54b41; }.quality-hero.failed > span { background: #f9e5e1; }.quality-list { border: 1px solid #e0e7f1; border-radius: 10px; overflow: hidden; }.quality-list > div { align-items: center; border-bottom: 1px solid #edf1f6; display: grid; gap: 7px; grid-template-columns: 22px 1fr auto; padding: 8px; }.quality-list > div:last-child { border-bottom: 0; }.quality-list > div > span { align-items: center; background: #e8f7f0; border-radius: 50%; color: #16875b; display: flex; height: 20px; justify-content: center; width: 20px; }.quality-list > div.failed > span { background: #fde9e6; color: #c54c42; }.quality-list svg { height: 10px; width: 10px; }.quality-list > div > div { display: grid; gap: 2px; }.quality-list strong { color: #41546f; font-size: 12px; }.quality-list small { color: #8b98aa; font-size: 12px; line-height: 1.35; }.quality-list b { background: #edf3ff; border-radius: 99px; color: #3764b5; font-size: 12px; padding: 4px 5px; }.quality-pass { background: #e7f8ef!important; color: #16865b!important; }.quality-fail { background: #fff0ed!important; color: #bc4a42!important; } +.quality-hero { align-items: center; border: 1px solid; border-radius: 11px; display: grid; gap: 9px; grid-template-columns: 38px 1fr; margin-bottom: 12px; padding: 11px; }.quality-hero > span { align-items: center; border-radius: 10px; display: flex; height: 36px; justify-content: center; }.quality-hero svg { height: 18px; width: 18px; }.quality-hero > div { display: grid; gap: 4px; }.quality-hero strong { font-size: 12px; }.quality-hero small { font-size: 12px; line-height: 1.45; }.quality-hero.passed { background: #eff9f5; border-color: #bde6d5; color: #217558; }.quality-hero.passed > span { background: #dff4eb; }.quality-hero.pending { background: #f3f7ff; border-color: #c9d8f8; color: #3764a5; }.quality-hero.pending > span { background: #e4edff; }.quality-hero.failed { background: #fff4f2; border-color: #f0c8c2; color: #a54b41; }.quality-hero.failed > span { background: #f9e5e1; }.quality-list { border: 1px solid #e0e7f1; border-radius: 10px; overflow: hidden; }.quality-list > div { align-items: center; border-bottom: 1px solid #edf1f6; display: grid; gap: 7px; grid-template-columns: 22px 1fr auto; padding: 8px; }.quality-list > div:last-child { border-bottom: 0; }.quality-list > div > span { align-items: center; background: #e8f7f0; border-radius: 50%; color: #16875b; display: flex; height: 20px; justify-content: center; width: 20px; }.quality-list > div.failed > span { background: #fde9e6; color: #c54c42; }.quality-list svg { height: 10px; width: 10px; }.quality-list > div > div { display: grid; gap: 2px; }.quality-list strong { color: #41546f; font-size: 12px; }.quality-list small { color: #8b98aa; font-size: 12px; line-height: 1.35; }.quality-list b { background: #edf3ff; border-radius: 99px; color: #3764b5; font-size: 12px; padding: 4px 5px; }.quality-pass { background: #e7f8ef!important; color: #16865b!important; }.quality-pending { background: #edf3ff!important; color: #3764a5!important; }.quality-fail { background: #fff0ed!important; color: #bc4a42!important; } .git-card { background: #0d1830; border-radius: 11px; color: white; margin-top: 12px; padding: 11px; } .git-card > div { align-items: center; display: flex; gap: 8px; }.git-card > div > svg { color: #78a4ff; height: 16px; width: 16px; }.git-card > div span { display: grid; gap: 2px; }.git-card strong { font-size: 12px; }.git-card small { color: #8090ad; font-size: 12px; }.git-card p { color: #9eacc4; font-size: 12px; line-height: 1.5; }.git-card button { align-items: center; background: #1f5edb; border: 0; border-radius: 7px; color: white; display: flex; font-size: 12px; gap: 6px; justify-content: center; min-height: 31px; width: 100%; }.git-card button svg { height: 11px; width: 11px; } .checkpoint-list { display: grid; gap: 7px; } diff --git a/components/drops-studio.tsx b/components/drops-studio.tsx index 0ff19c0..349e390 100644 --- a/components/drops-studio.tsx +++ b/components/drops-studio.tsx @@ -1867,7 +1867,8 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { createdAt: now, updatedAt: now, }; - const studioHref = `/studio/${project.id}?panel=director&autobuild=1`; + const buildRequestId = crypto.randomUUID(); + const studioHref = `/studio/${project.id}?panel=director&autobuild=1&buildRequest=${encodeURIComponent(buildRequestId)}`; void router.prefetch(studioHref); void warmProjectExperience(spec); const stored = await saveProjectSafely(project, { diff --git a/components/project-studio.tsx b/components/project-studio.tsx index d48c1b7..675d474 100644 --- a/components/project-studio.tsx +++ b/components/project-studio.tsx @@ -152,6 +152,10 @@ import { studioAccountDisplayName, studioAccountInitial, } from "@/lib/studio-account-profile"; +import { + clearStudioBuildIntent, + consumeStudioBuildIntent, +} from "@/lib/studio-build-intent"; type InspectorTab = | "project" @@ -174,6 +178,7 @@ type ProjectSyncStatus = | "synced" | "conflict" | "error"; +type StudioBuildLifecycle = "idle" | "running" | "ready" | "blocked"; const STUDIO_PANEL_WIDTH_KEY = "drops-studio:studio-panel-width"; const STUDIO_PANEL_MIN_WIDTH = 320; @@ -650,6 +655,17 @@ function projectV2BuildEvidence(projectV2?: ProjectV2): { return { passed, total: 5, verified: passed === 5 }; } +function projectV2BuildLifecycle(projectV2?: ProjectV2): StudioBuildLifecycle { + if (projectV2BuildEvidence(projectV2).verified) return "ready"; + if ( + projectV2?.preview?.status === "failed" + && projectV2.preview.projectRevision === projectV2.revision + ) { + return "blocked"; + } + return "idle"; +} + function currentProjectV2PreviewUrl(projectV2?: ProjectV2): string | null { if ( !projectV2?.preview?.url @@ -691,6 +707,11 @@ export function ProjectStudio() { const cloudRevisionRef = useRef(null); const projectV2CloudRevisionRef = useRef(null); const [project, setProject] = useState(null); + const [autoBuildRequestId, setAutoBuildRequestId] = useState( + null, + ); + const [buildLifecycle, setBuildLifecycle] = + useState("idle"); const [accountProfile, setAccountProfile] = useState<{ name: string; email?: string; @@ -1153,6 +1174,17 @@ export function ProjectStudio() { setRuntimeSmoke(null); setProject(migrated); setRuntimeProject(migrated); + setBuildLifecycle(projectV2BuildLifecycle(migrated.projectV2)); + const buildRequestId = consumeStudioBuildIntent( + { + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + }, + (url) => window.history.replaceState(window.history.state, "", url), + () => window.crypto.randomUUID(), + ); + setAutoBuildRequestId(buildRequestId); const requestedPanel = new URLSearchParams(window.location.search).get( "panel", ); @@ -1632,6 +1664,7 @@ export function ProjectStudio() { committedProjectRef.current = next; setProject(next); setDirty(true); + setBuildLifecycle(projectV2BuildLifecycle(nextProjectV2)); setProjectSyncStatus(storageRevision !== undefined ? "synced" : "local"); const save = () => saveProjectSafely(next, { @@ -1662,12 +1695,36 @@ export function ProjectStudio() { [], ); + const settleAutoBuildIntent = useCallback((requestId: string) => { + const cleared = clearStudioBuildIntent( + { + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + }, + requestId, + (url) => window.history.replaceState(window.history.state, "", url), + ); + if (cleared) { + setAutoBuildRequestId((current) => current === requestId ? null : current); + } + }, []); + const recordBuilderAgentEvent = useCallback( (event: { phase: "snapshot" | "sandbox" | "verification" | "preview"; status: "active" | "done" | "blocked"; message: string; }) => { + setBuildLifecycle( + event.status === "blocked" + ? "blocked" + : event.phase === "preview" && event.status === "done" + ? "ready" + : event.status === "active" + ? "running" + : "running", + ); const current = projectRef.current; if (!current) return; const eventId = `builder-${current.id}-${event.phase}`; @@ -3577,9 +3634,13 @@ export function ProjectStudio() { : externalSetup ? "Needs connection" : hasProjectV2 - ? builderEvidence.verified - ? "Ready" - : "Draft" + ? buildLifecycle === "running" + ? "Building" + : buildLifecycle === "blocked" + ? "Needs retry" + : builderEvidence.verified + ? "Ready" + : "Ready to build" : "Draft"} @@ -3720,6 +3781,8 @@ export function ProjectStudio() { {hasProjectV2 @@ -4898,7 +4963,13 @@ export function ProjectStudio() {
@@ -4908,7 +4979,11 @@ export function ProjectStudio() { {builderEvidence.verified ? "Project V2 build verified" : hasProjectV2 - ? "Project V2 build pending" + ? buildLifecycle === "running" + ? "Building and checking your app" + : buildLifecycle === "blocked" + ? "Build needs another pass" + : "Ready for a verified build" : quality.readyToPublish ? releaseLabel : "Build needs attention"} @@ -4917,7 +4992,11 @@ export function ProjectStudio() { {builderEvidence.verified ? "Typecheck, lint, tests, production build and live Sandbox preview passed for this file revision. The legacy score below applies only to standalone /p publishing." : hasProjectV2 - ? `${builderEvidence.passed}/${builderEvidence.total} current-revision checks are ready. Open Code to run the remaining checks and start the live preview.` + ? buildLifecycle === "running" + ? "The saved files are running through install, checks and live preview startup now. Progress remains visible in Chat." + : buildLifecycle === "blocked" + ? "Your files and last working preview are safe. Open Code and choose Retry when you are ready." + : `${builderEvidence.passed}/${builderEvidence.total} current-revision checks have passed. Open Code and choose Build & verify to create or refresh the live preview.` : externalSetup ? "The web setup app can publish, but the external outcome is not live until it is connected and verified." : "Deterministic checks run on every edit and before every publish."} diff --git a/components/project-v2-studio-surface.tsx b/components/project-v2-studio-surface.tsx index 5efeda4..0f10012 100644 --- a/components/project-v2-studio-surface.tsx +++ b/components/project-v2-studio-surface.tsx @@ -61,7 +61,11 @@ import type { AgentRunTrace } from "@/lib/agent/evals/types"; import styles from "./project-v2-studio-surface.module.css"; const AUTO_BUILD_KEY = "drops-studio:v2-auto-build"; -const AUTO_BUILD_LEASE_MS = 6 * 60 * 1000; +const BUILD_STATUS_POLL_MS = 10_000; + +function autoBuildRunId(requestId: string): string { + return `auto:${requestId}`; +} interface BuilderApiPayload { result?: BuilderAgentResult; @@ -108,6 +112,8 @@ type ProjectV2StorageMode = "checking" | "cloud" | "local"; export interface ProjectV2StudioSurfaceProps { project: ProjectV2; provider: ProjectProvider; + autoBuildRequestId?: string | null; + onAutoBuildSettled?: (requestId: string) => void; onProjectChange: (project: ProjectV2, storageRevision?: number) => void; onAgentEvent?: (event: { phase: "snapshot" | "sandbox" | "verification" | "preview"; @@ -332,6 +338,8 @@ export const ProjectV2StudioSurface = forwardRef< >(function ProjectV2StudioSurface({ project, provider, + autoBuildRequestId = null, + onAutoBuildSettled, onProjectChange, onAgentEvent, onNotify, @@ -373,6 +381,8 @@ export const ProjectV2StudioSurface = forwardRef< const [agentTrace, setAgentTrace] = useState(null); const mounted = useRef(true); const autoStarted = useRef(null); + const activeAutoBuildRequest = useRef(null); + const [autoBuildRetry, setAutoBuildRetry] = useState(0); const builderAbort = useRef(null); const activeRunRef = useRef(false); const snapshotSyncQueue = useRef>(Promise.resolve()); @@ -604,13 +614,20 @@ export const ProjectV2StudioSurface = forwardRef< const runBuilder = useCallback(async ( mode: "build" | "edit" | "repair", prompt: string, + options: { + requestId?: string; + onTerminalResponse?: (response: Response) => void; + } = {}, ): Promise => { if (activeRunRef.current) return null; activeRunRef.current = true; + activeAutoBuildRequest.current = options.requestId ?? null; let activePhase: "snapshot" | "sandbox" | "verification" = "snapshot"; const controller = new AbortController(); builderAbort.current = controller; let statusTimer: ReturnType | null = null; + let runtimeReadyObserved = false; + let runSettled = false; setBusy("task:build"); setAgentState("running"); setAgentSummary( @@ -642,9 +659,33 @@ export const ProjectV2StudioSurface = forwardRef< status: "active", message: "Starting the isolated Node 24 Sandbox and syncing real project files…", }); + const refreshBuildProgress = async () => { + const state = await refreshSandboxStatus(snapshot.project.id); + if ( + runSettled + || controller.signal.aborted + || runtimeReadyObserved + || state.status !== "running" + ) { + return; + } + runtimeReadyObserved = true; + activePhase = "verification"; + onAgentEvent?.({ + phase: "sandbox", + status: "done", + message: "The isolated Node 24 Sandbox is running.", + }); + onAgentEvent?.({ + phase: "verification", + status: "active", + message: "Installing dependencies and running the declared checks…", + }); + }; statusTimer = setInterval(() => { - void refreshSandboxStatus(snapshot.project.id).catch(() => undefined); - }, 4_000); + void refreshBuildProgress().catch(() => undefined); + }, BUILD_STATUS_POLL_MS); + void refreshBuildProgress().catch(() => undefined); const response = await fetch("/api/builder/agent", { method: "POST", credentials: "same-origin", @@ -655,8 +696,11 @@ export const ProjectV2StudioSurface = forwardRef< prompt, mode, provider: providerSelection(provider), + ...(options.requestId ? { buildRequestId: options.requestId } : {}), }), }); + options.onTerminalResponse?.(response); + runSettled = true; if (statusTimer) { clearInterval(statusTimer); statusTimer = null; @@ -665,11 +709,14 @@ export const ProjectV2StudioSurface = forwardRef< if (!payload.result) { throw new Error(payload.error ?? "Builder agent returned no verifiable result."); } - onAgentEvent?.({ - phase: "sandbox", - status: "done", - message: "Project files are running inside the isolated Node 24 Sandbox.", - }); + if (!runtimeReadyObserved) { + runtimeReadyObserved = true; + onAgentEvent?.({ + phase: "sandbox", + status: "done", + message: "Project files are running inside the isolated Node 24 Sandbox.", + }); + } activePhase = "verification"; onAgentEvent?.({ phase: "verification", @@ -699,9 +746,6 @@ export const ProjectV2StudioSurface = forwardRef< message: "Live Sandbox preview is ready. You can keep chatting to edit multiple files.", }); } else { - window.sessionStorage.removeItem( - `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`, - ); onAgentEvent?.({ phase: "verification", status: "blocked", @@ -710,9 +754,7 @@ export const ProjectV2StudioSurface = forwardRef< } return payload.result; } catch (error) { - window.sessionStorage.removeItem( - `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`, - ); + runSettled = true; const cancelled = controller.signal.aborted; const failure = cancelled ? "Build stopped. Your saved files and last working preview are unchanged." @@ -737,20 +779,19 @@ export const ProjectV2StudioSurface = forwardRef< onNotify?.(failure); return null; } finally { + runSettled = true; if (statusTimer) clearInterval(statusTimer); if (builderAbort.current === controller) builderAbort.current = null; activeRunRef.current = false; - window.sessionStorage.removeItem( - `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`, - ); + if (activeAutoBuildRequest.current === options.requestId) { + activeAutoBuildRequest.current = null; + } if (mounted.current) setBusy(null); } }, [ absorbBuilderResult, onAgentEvent, onNotify, - project.id, - project.revision, provider, refreshSandboxStatus, syncSnapshot, @@ -758,26 +799,84 @@ export const ProjectV2StudioSurface = forwardRef< useEffect(() => { if ( - autoStarted.current === `${project.id}:${project.revision}` || + !autoBuildRequestId || + autoStarted.current === autoBuildRequestId || storageMode !== "cloud" || - project.manifest.framework.name !== "nextjs" || - project.preview?.status === "ready" + project.manifest.framework.name !== "nextjs" ) { return; } - const key = `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`; - const lease = Number(window.sessionStorage.getItem(key)); - if (Number.isFinite(lease) && Date.now() - lease < AUTO_BUILD_LEASE_MS) return; - autoStarted.current = `${project.id}:${project.revision}`; - window.sessionStorage.setItem(key, String(Date.now())); + const key = `${AUTO_BUILD_KEY}:${project.id}:${autoBuildRequestId}`; + if (project.preview?.status === "ready") { + window.sessionStorage.removeItem(key); + onAutoBuildSettled?.(autoBuildRequestId); + return; + } + const persistedRun = project.runs.find( + (run) => run.id === autoBuildRunId(autoBuildRequestId), + ); + autoStarted.current = autoBuildRequestId; + if (persistedRun?.status === "running" || persistedRun?.status === "queued") { + const timer = window.setTimeout(() => { + void loadProjectV2FromCloud(project.id).then((remote) => { + if (!mounted.current) return; + autoStarted.current = null; + if (remote) onProjectChange(remote.project, remote.storageRevision); + setAutoBuildRetry((value) => value + 1); + }).catch(() => { + if (!mounted.current) return; + autoStarted.current = null; + setAutoBuildRetry((value) => value + 1); + }); + }, BUILD_STATUS_POLL_MS); + return () => window.clearTimeout(timer); + } + let launched = false; const timer = window.setTimeout(() => { + launched = true; + window.sessionStorage.setItem(key, autoBuildRequestId); + let retryAfterInProgress = false; void runBuilder( "build", "Build this Project V2 exactly as planned, verify every declared check, start the real preview, exercise its primary interaction, and create a checkpoint only when the release gate passes.", - ); + { + requestId: autoBuildRequestId, + onTerminalResponse: (response) => { + if (response.status === 202) { + retryAfterInProgress = true; + return; + } + window.sessionStorage.removeItem(key); + onAutoBuildSettled?.(autoBuildRequestId); + }, + }, + ).finally(() => { + if (!retryAfterInProgress || !mounted.current) return; + window.setTimeout(() => { + if (!mounted.current) return; + autoStarted.current = null; + setAutoBuildRetry((value) => value + 1); + }, BUILD_STATUS_POLL_MS); + }); }, 50); - return () => window.clearTimeout(timer); - }, [project.id, project.manifest.framework.name, project.preview?.status, project.revision, runBuilder, storageMode]); + return () => { + window.clearTimeout(timer); + if (!launched && autoStarted.current === autoBuildRequestId) { + autoStarted.current = null; + } + }; + }, [ + autoBuildRequestId, + autoBuildRetry, + onAutoBuildSettled, + onProjectChange, + project.id, + project.manifest.framework.name, + project.preview?.status, + project.runs, + runBuilder, + storageMode, + ]); const saveSnapshot = useCallback(async (next: ProjectV2) => { if (storageMode === "local") { @@ -984,14 +1083,21 @@ export const ProjectV2StudioSurface = forwardRef< } const stopActiveRun = useCallback(async () => { + const automaticOwner = activeAutoBuildRequest.current; builderAbort.current?.abort(); await runRuntimeAction("stop").catch(() => undefined); + if (autoBuildRequestId && automaticOwner === autoBuildRequestId) { + window.sessionStorage.removeItem( + `${AUTO_BUILD_KEY}:${project.id}:${autoBuildRequestId}`, + ); + onAutoBuildSettled?.(autoBuildRequestId); + } if (!mounted.current) return; setBusy(null); setAgentState("idle"); setAgentSummary("Build stopped. Your saved files and last working preview are unchanged."); setSandbox((current) => ({ ...current, status: "stopped" })); - }, [runRuntimeAction]); + }, [autoBuildRequestId, onAutoBuildSettled, project.id, runRuntimeAction]); useImperativeHandle(ref, () => ({ run: runBuilder, diff --git a/docs/design/current-home-actual.png b/docs/design/current-home-actual.png index 77a648f..782c842 100644 Binary files a/docs/design/current-home-actual.png and b/docs/design/current-home-actual.png differ diff --git a/docs/design/current-studio-actual.png b/docs/design/current-studio-actual.png index 550edb8..4e7dcf8 100644 Binary files a/docs/design/current-studio-actual.png and b/docs/design/current-studio-actual.png differ diff --git a/e2e/contracts/v0-studio-flow.spec.ts b/e2e/contracts/v0-studio-flow.spec.ts index a94003c..2260d54 100644 --- a/e2e/contracts/v0-studio-flow.spec.ts +++ b/e2e/contracts/v0-studio-flow.spec.ts @@ -10,6 +10,7 @@ test("Build opens the unified Director workspace with an honest live-preview han page, }, testInfo) => { const assertCleanRuntime = installRuntimeGuards(page) + let builderAgentCalls = 0 await page.route("**/api/account", async (route) => { await route.fulfill({ @@ -28,6 +29,7 @@ test("Build opens the unified Director workspace with an honest live-preview han }) }) await page.route("**/api/builder/agent", async (route) => { + builderAgentCalls += 1 await route.fulfill({ status: 200, contentType: "application/json", @@ -65,8 +67,9 @@ test("Build opens the unified Director workspace with an honest live-preview han await page.locator('[data-preset="crypto-radio"]').click() await page.getByRole("button", { name: "Build now", exact: true }).click() - await page.waitForURL(/\/studio\/[a-f0-9-]+\?panel=director&autobuild=1$/i) + await page.waitForURL(/\/studio\/[a-f0-9-]+\?panel=director&autobuild=1&buildRequest=[a-f0-9-]+$/i) await expect(page.locator(".project-studio-layout")).toHaveClass(/tab-director/) + await expect(page).toHaveURL(/\/studio\/[a-f0-9-]+\?panel=director$/i) await expect(page.getByText("Drops Agent", { exact: true })).toBeVisible() await expect(page.getByLabel("AI model")).toHaveValue("free") await expect(page.getByText("Studio Maker", { exact: true })).toBeVisible() @@ -127,8 +130,14 @@ test("Build opens the unified Director workspace with an honest live-preview han const dialog = page.getByRole("dialog") await expect(dialog.getByText("Connections Hub", { exact: true })).toBeVisible() await dialog.getByRole("button", { name: "Close connections" }).click() - await expect(page).toHaveURL(/\/studio\/[a-f0-9-]+\?panel=director&autobuild=1$/i) + await expect(page).toHaveURL(/\/studio\/[a-f0-9-]+\?panel=director$/i) await expect(page.getByText("Drops Agent", { exact: true })).toBeVisible() + await expect.poll(() => builderAgentCalls).toBe(1) + await page.reload({ waitUntil: "domcontentloaded" }) + await expect(page.getByText("Drops Agent", { exact: true })).toBeVisible() + await page.waitForTimeout(500) + expect(builderAgentCalls).toBe(1) + await assertCleanRuntime() }) diff --git a/e2e/fixtures/project-v2-ui-test.ts b/e2e/fixtures/project-v2-ui-test.ts index f10e3f8..76dccbb 100644 --- a/e2e/fixtures/project-v2-ui-test.ts +++ b/e2e/fixtures/project-v2-ui-test.ts @@ -227,19 +227,17 @@ export async function prepareProjectV2UiPage( }); await page.addInitScript( - ({ key, value, autoBuildKey, seedKey }) => { + ({ key, value, seedKey }) => { if (window.top !== window) return; if (window.sessionStorage.getItem(seedKey) === "1") return; window.localStorage.clear(); window.sessionStorage.clear(); window.localStorage.setItem(key, value); - window.sessionStorage.setItem(autoBuildKey, String(Date.now())); window.sessionStorage.setItem(seedKey, "1"); }, { key: PROJECTS_STORAGE_KEY, value: JSON.stringify([project]), - autoBuildKey: `drops-studio:v2-auto-build:${id}:${projectV2.revision}`, seedKey: `drops-studio:e2e-project-v2-seeded:${id}`, }, ); diff --git a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux-system.png b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux-system.png index cf1ee33..93d4d96 100644 Binary files a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux-system.png and b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux-system.png differ diff --git a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.png b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.png index 86531a3..93d4d96 100644 Binary files a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.png and b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.png differ diff --git a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux-system.png b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux-system.png index 3ae6347..2aa172f 100644 Binary files a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux-system.png and b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux-system.png differ diff --git a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.png b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.png index 28e0c68..2aa172f 100644 Binary files a/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.png and b/e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.png differ diff --git a/lib/studio-build-intent.ts b/lib/studio-build-intent.ts new file mode 100644 index 0000000..4b0ebfc --- /dev/null +++ b/lib/studio-build-intent.ts @@ -0,0 +1,55 @@ +export const STUDIO_AUTO_BUILD_PARAM = "autobuild"; +export const STUDIO_BUILD_REQUEST_PARAM = "buildRequest"; + +export interface StudioLocationSnapshot { + pathname: string; + search: string; + hash?: string; +} + +/** + * Normalizes the creation-only build marker without consuming it. The marker + * remains recoverable across reloads and the Connections round-trip until the + * builder returns a terminal receipt. + */ +export function consumeStudioBuildIntent( + location: StudioLocationSnapshot, + replaceUrl: (url: string) => void, + createRequestId: () => string, +): string | null { + const params = new URLSearchParams(location.search); + if (params.get(STUDIO_AUTO_BUILD_PARAM) !== "1") return null; + + const requestId = params.get(STUDIO_BUILD_REQUEST_PARAM)?.trim() + || createRequestId(); + params.set(STUDIO_AUTO_BUILD_PARAM, "1"); + params.set(STUDIO_BUILD_REQUEST_PARAM, requestId); + + const query = params.toString(); + const normalized = `${location.pathname}${query ? `?${query}` : ""}${location.hash ?? ""}`; + const current = `${location.pathname}${location.search}${location.hash ?? ""}`; + if (normalized !== current) replaceUrl(normalized); + return requestId; +} + +/** Clears only the matching terminal build intent, preserving newer requests. */ +export function clearStudioBuildIntent( + location: StudioLocationSnapshot, + requestId: string, + replaceUrl: (url: string) => void, +): boolean { + const params = new URLSearchParams(location.search); + if ( + params.get(STUDIO_AUTO_BUILD_PARAM) !== "1" + || params.get(STUDIO_BUILD_REQUEST_PARAM)?.trim() !== requestId + ) { + return false; + } + params.delete(STUDIO_AUTO_BUILD_PARAM); + params.delete(STUDIO_BUILD_REQUEST_PARAM); + const query = params.toString(); + replaceUrl( + `${location.pathname}${query ? `?${query}` : ""}${location.hash ?? ""}`, + ); + return true; +} diff --git a/lib/vercel-sandbox-runtime-adapter.ts b/lib/vercel-sandbox-runtime-adapter.ts index 3b70b73..9b50ccb 100644 --- a/lib/vercel-sandbox-runtime-adapter.ts +++ b/lib/vercel-sandbox-runtime-adapter.ts @@ -938,17 +938,18 @@ export class VercelSandboxRuntimeAdapter implements ProjectRuntimeAdapter { async cleanupIdle(options: RuntimeCleanupOptions): Promise { const provider = await this.#provider(); - const limit = Math.min(Math.max(options.limit ?? 25, 1), 100); + // The stable Sandbox list API currently rejects page sizes above 50. + const limit = Math.min(Math.max(options.limit ?? 25, 1), 50); const stopped: string[] = []; const failed: string[] = []; let inspected = 0; const sandboxes = await provider.list({ namePrefix: "ds2-", + sortBy: "name", limit, ...this.#credentialsInput(), }); for await (const record of sandboxes) { - if (inspected >= limit) break; inspected += 1; if (record.updatedAt >= options.idleBefore.getTime()) continue; try { diff --git a/tests/builder-agent-route.test.mjs b/tests/builder-agent-route.test.mjs index e8b246e..c76892a 100644 --- a/tests/builder-agent-route.test.mjs +++ b/tests/builder-agent-route.test.mjs @@ -367,6 +367,61 @@ test("a disconnected client aborts the active build and preserves a restartable assert.equal(deps.calls.stop, 1); }); +test("automatic build request IDs deduplicate an active run and remain restartable", async () => { + const deps = dependencies(); + const controller = new AbortController(); + const buildRequestId = "autobuild-request-0001"; + deps.deterministicFallback = { + async run() { + await new Promise(() => {}); + }, + }; + const body = { + projectId: "builder-route-project", + prompt: "Build once across a Studio reload.", + mode: "build", + buildRequestId, + provider: { provider: "free" }, + }; + const pending = handleBuilderAgentRequest( + request("/api/builder/agent", body, {}, controller.signal), + deps, + ); + for (let attempt = 0; attempt < 20; attempt += 1) { + if (deps.getStored().runs.some( + (run) => run.id === `auto:${buildRequestId}` && run.status === "running", + )) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const duplicate = await handleBuilderAgentRequest( + request("/api/builder/agent", body), + deps, + ); + assert.equal(duplicate.status, 202); + assert.equal((await duplicate.json()).code, "BUILDER_REQUEST_IN_PROGRESS"); + + controller.abort(); + assert.equal((await pending).status, 499); + assert.equal( + deps.getStored().runs.find((run) => run.id === `auto:${buildRequestId}`)?.status, + "stopped", + ); + + delete deps.deterministicFallback; + const restarted = await handleBuilderAgentRequest( + request("/api/builder/agent", body), + deps, + ); + assert.equal(restarted.status, 200, JSON.stringify(await restarted.clone().json())); + const payload = await restarted.json(); + assert.equal( + payload.result.project.runs.find( + (run) => run.id === `auto:${buildRequestId}`, + )?.status, + "succeeded", + ); +}); + test("runtime preview persists and returns real Project V2 preview metadata", async () => { const deps = dependencies(); const response = await handleBuilderRuntimeRequest(request("/api/builder/runtime", { diff --git a/tests/studio-build-intent.test.mjs b/tests/studio-build-intent.test.mjs new file mode 100644 index 0000000..e29603f --- /dev/null +++ b/tests/studio-build-intent.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { + clearStudioBuildIntent, + consumeStudioBuildIntent, +} = await import("../lib/studio-build-intent.ts"); + +test("consumes a new-project auto-build marker exactly once", () => { + const replacements = []; + const requestId = consumeStudioBuildIntent( + { + pathname: "/studio/project-1", + search: "?panel=director&autobuild=1", + hash: "#chat", + }, + (url) => replacements.push(url), + () => "request-1", + ); + + assert.equal(requestId, "request-1"); + assert.deepEqual(replacements, [ + "/studio/project-1?panel=director&autobuild=1&buildRequest=request-1#chat", + ]); + + const reopened = consumeStudioBuildIntent( + { + pathname: "/studio/project-1", + search: "?panel=director", + }, + () => assert.fail("ordinary reopen must not mutate the URL"), + () => assert.fail("ordinary reopen must not create a build request"), + ); + assert.equal(reopened, null); +}); + +test("preserves an explicit idempotency request until a terminal receipt", () => { + let replacement = ""; + const requestId = consumeStudioBuildIntent( + { + pathname: "/studio/project-2", + search: "?autobuild=1&buildRequest=stable-request&panel=code", + }, + (url) => { + replacement = url; + }, + () => assert.fail("explicit request id should be reused"), + ); + + assert.equal(requestId, "stable-request"); + assert.equal(replacement, ""); +}); + +test("clears only the matching terminal build request", () => { + let replacement = ""; + const cleared = clearStudioBuildIntent( + { + pathname: "/studio/project-2", + search: "?autobuild=1&buildRequest=stable-request&panel=code", + hash: "#files", + }, + "stable-request", + (url) => { + replacement = url; + }, + ); + assert.equal(cleared, true); + assert.equal(replacement, "/studio/project-2?panel=code#files"); + + assert.equal( + clearStudioBuildIntent( + { + pathname: "/studio/project-2", + search: "?autobuild=1&buildRequest=newer-request", + }, + "stable-request", + () => assert.fail("a stale completion must not clear a newer request"), + ), + false, + ); +}); diff --git a/tests/vercel-sandbox-runtime-cleanup.test.mjs b/tests/vercel-sandbox-runtime-cleanup.test.mjs index aeb1352..55d03e5 100644 --- a/tests/vercel-sandbox-runtime-cleanup.test.mjs +++ b/tests/vercel-sandbox-runtime-cleanup.test.mjs @@ -242,7 +242,7 @@ test("protected cleanup route stops only sandboxes older than the configured idl }); assert.equal(response.status, 200); assert.equal(received.idleBefore.toISOString(), "2026-07-30T11:45:00.000Z"); - assert.equal(received.limit, 100); + assert.equal(received.limit, 50); assert.deepEqual((await response.json()).stopped, ["ds2-idle"]); }); diff --git a/tests/vercel-sandbox-runtime.test.mjs b/tests/vercel-sandbox-runtime.test.mjs index 09c3ee9..b0f328b 100644 --- a/tests/vercel-sandbox-runtime.test.mjs +++ b/tests/vercel-sandbox-runtime.test.mjs @@ -353,10 +353,47 @@ test("checkpoint restore uses a full secret-free file snapshot and idle cleanup checkpoint.files.find((file) => file.path === "app/page.tsx").content = "export default function Page(){ return
Restored
; }"; const restored = await adapter.restoreCheckpoint(context, checkpoint, handle); assert.match(await adapter.readFile(restored, "app/page.tsx"), /Restored/); - const cleanup = await adapter.cleanupIdle({ idleBefore: new Date("2026-07-30T12:00:00.000Z") }); + const cleanup = await adapter.cleanupIdle({ + idleBefore: new Date("2026-07-30T12:00:00.000Z"), + limit: 100, + }); assert.equal(cleanup.inspected, 1); assert.deepEqual(cleanup.stopped, [sandbox.name]); assert.equal(sandbox.stopped, true); + assert.equal(mock.calls.list[0].limit, 50); + assert.equal(mock.calls.list[0].sortBy, "name"); +}); + +test("idle cleanup auto-paginates beyond the provider page size", async () => { + const sandbox = new MockSandbox(); + const records = Array.from({ length: 75 }, (_, index) => ({ + name: `ds2-cleanup-${String(index).padStart(3, "0")}`, + status: "running", + createdAt: 1, + updatedAt: 1, + })); + const listCalls = []; + const adapter = new VercelSandboxRuntimeAdapter({ + credentials: null, + provider: { + async getOrCreate() { return sandbox; }, + async get() { return sandbox; }, + async list(input) { + listCalls.push(input); + return (async function* () { + for (const record of records) yield record; + })(); + }, + }, + }); + const cleanup = await adapter.cleanupIdle({ + idleBefore: new Date("2026-07-30T12:00:00.000Z"), + limit: 50, + }); + assert.equal(listCalls[0].limit, 50); + assert.equal(cleanup.inspected, 75); + assert.equal(cleanup.stopped.length, 75); + assert.equal(cleanup.failed.length, 0); }); test("command and log output is truncated and redacted before leaving the adapter", async () => {