diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 41eab45..8d88546 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -257,6 +257,17 @@ before executing against a user-connected OAuth account. ProjectSandbox records elapsed sandbox-hours to the same `QuotaTracker` as a soft meter so Settings can show real monthly sandbox consumption without blocking sandbox file/process work. + +Opening Files admits code-server startup durably instead of holding the browser request open while +Daytona starts. ProjectSandbox stores one generation-checked startup operation, schedules it through +the alarm shared with run keepalive, and reports bounded queued, sandbox-starting, files-starting, +ready, or failed status. Alarm handling runs keepalive first so every retry leaves the next lease +refresh scheduled; a retriable Files failure then moves that alarm forward to its earlier retry. +At-least-once delivery is safe because every completion write compares the operation generation and +code-server keeps its deterministic process identity. The legacy session GET remains synchronous for +rollout compatibility, while a fresh ready record tied to the current Daytona sandbox lets the normal +path mint its short-lived capability without re-probing the container. Preview-origin credentials +remain owned by the preview proxy and are never persisted in the startup record. Lazy workspace materialization resolves the effective project ceiling from the authoritative entitlement row, including an operator-granted override when present, before creating a project. diff --git a/apps/agent-worker/src/durable-objects/agent-run-workspace.ts b/apps/agent-worker/src/durable-objects/agent-run-workspace.ts index 786fe14..c413945 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workspace.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workspace.ts @@ -58,7 +58,15 @@ async function resolveWorkspace(input: WorkspaceResolverInput): Promise { + return updateAlarmDeadline(storage, CODE_SERVER_DEADLINE_KEY, scheduledAtMs); +} + +export function scheduleKeepaliveAlarm( + storage: DurableObjectStorage, + scheduledAtMs: number | null, +): Promise { + return updateAlarmDeadline(storage, KEEPALIVE_DEADLINE_KEY, scheduledAtMs); +} + +export async function projectSandboxAlarmTasksDue( + storage: DurableObjectStorage, +): Promise { + const [codeServerAt, keepaliveAt] = await Promise.all([ + storage.get(CODE_SERVER_DEADLINE_KEY), + storage.get(KEEPALIVE_DEADLINE_KEY), + ]); + const dueAt = Date.now() + ALARM_DUE_TOLERANCE_MS; + // A pre-multiplexer deployment may already have a keepalive alarm but no + // deadline keys. Treat that one alarm as keepalive work and migrate in place. + const isLegacyAlarm = codeServerAt === undefined && keepaliveAt === undefined; + return { + codeServer: codeServerAt !== undefined && codeServerAt <= dueAt, + keepalive: isLegacyAlarm || (keepaliveAt !== undefined && keepaliveAt <= dueAt), + }; +} + +export function rescheduleProjectSandboxAlarm(storage: DurableObjectStorage): Promise { + return storage.transaction((transaction) => applyEarliestAlarm(transaction)); +} + +function updateAlarmDeadline( + storage: DurableObjectStorage, + key: string, + scheduledAtMs: number | null, +): Promise { + return storage.transaction(async (transaction) => { + await adoptLegacyKeepaliveAlarm(transaction); + if (scheduledAtMs === null) { + await transaction.delete(key); + } else { + await transaction.put(key, scheduledAtMs); + } + await applyEarliestAlarm(transaction); + }); +} + +async function adoptLegacyKeepaliveAlarm(transaction: DurableObjectTransaction): Promise { + const [codeServerAt, keepaliveAt] = await Promise.all([ + transaction.get(CODE_SERVER_DEADLINE_KEY), + transaction.get(KEEPALIVE_DEADLINE_KEY), + ]); + if (codeServerAt !== undefined || keepaliveAt !== undefined) return; + const legacyAlarmAt = await transaction.getAlarm(); + if (legacyAlarmAt !== null) { + await transaction.put(KEEPALIVE_DEADLINE_KEY, legacyAlarmAt); + } +} + +async function applyEarliestAlarm(transaction: DurableObjectTransaction): Promise { + const [codeServerAt, keepaliveAt] = await Promise.all([ + transaction.get(CODE_SERVER_DEADLINE_KEY), + transaction.get(KEEPALIVE_DEADLINE_KEY), + ]); + const deadlines = [codeServerAt, keepaliveAt].filter( + (value): value is number => value !== undefined, + ); + if (deadlines.length === 0) { + await transaction.deleteAlarm(); + return; + } + await transaction.setAlarm(Math.min(...deadlines)); +} diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-code-server-runtime.ts b/apps/agent-worker/src/durable-objects/project-sandbox-code-server-runtime.ts new file mode 100644 index 0000000..396b203 --- /dev/null +++ b/apps/agent-worker/src/durable-objects/project-sandbox-code-server-runtime.ts @@ -0,0 +1,568 @@ +import { APIError, createLogger, toAPIError } from "@cheatcode/observability"; +import { ErrorCodeSchema } from "@cheatcode/types"; +import { SandboxIdeStartupPhaseSchema, type SandboxIdeStartupStatus } from "@cheatcode/types/api"; +import { z } from "zod"; +import { shellQuote } from "../sandbox-support"; +import { scheduleCodeServerAlarm } from "./project-sandbox-alarm"; +import { + CODE_SERVER_DISPLAY_DIR, + CODE_SERVER_PORT, + CODE_SERVER_PROCESS_ID, + CODE_SERVER_SETTINGS_MARKER, + CODE_SERVER_START_TIMEOUT_MS, + codeServerFolderUrl, + codeServerStartCommand, + codeServerTrustedOrigins, +} from "./project-sandbox-code-server"; +import { WORKSPACE_DIR } from "./project-sandbox-content-support"; +import { DAYTONA_ID_KEY } from "./project-sandbox-lifecycle-support"; +import { buildPreviewUrl } from "./project-sandbox-preview"; +import type { ProcessRecord } from "./project-sandbox-process-support"; +import type { CoordinatedProcessOps, ProcessOps } from "./project-sandbox-processes"; +import type { ProjectCodeServerInput } from "./project-sandbox-runtime"; +import { ProjectCodeServerInputSchema } from "./project-sandbox-runtime"; +import type { SandboxRuntime } from "./project-sandbox-runtime-handle"; + +const CODE_SERVER_STARTUP_STATE_KEY = "code_server_startup_state_v1"; +const CODE_SERVER_STARTUP_VERSION = CODE_SERVER_SETTINGS_MARKER; +const READY_EVIDENCE_TTL_MS = 10_000; +const STARTUP_STALE_MS = 5 * 60_000; +const STARTUP_RETRY_DELAY_MS = 1_500; +const STARTUP_MAX_ATTEMPTS = 2; + +const StoredCodeServerStartupSchema = z.strictObject({ + attempt: z.number().int().min(0).max(STARTUP_MAX_ATTEMPTS), + errorCode: ErrorCodeSchema.optional(), + nextAttemptAtMs: z.number().int().positive().optional(), + operationId: z.string().uuid(), + phase: SandboxIdeStartupPhaseSchema, + processCmdId: z.string().min(1).max(500).optional(), + processSessionId: z.string().min(1).max(500).optional(), + requestedAtMs: z.number().int().positive(), + retriable: z.boolean().optional(), + sandboxId: z.string().min(1).max(500).optional(), + targetVersion: z.string().min(1).max(500), + updatedAtMs: z.number().int().positive(), +}); + +type StoredCodeServerStartup = z.infer; + +type CodeServerRuntime = Pick< + SandboxRuntime, + "client" | "ensureSandbox" | "previewHostname" | "previewSecret" | "releaseSha" | "storage" +>; + +type CodeServerProcessOps = Pick< + ProcessOps, + | "deleteProcessRecord" + | "deleteProcessesOnPort" + | "httpPortReady" + | "processRecord" + | "relaunchDevServer" + | "waitForPort" +>; + +type CodeServerCoordinatedProcessOps = Pick; + +interface CodeServerContext { + coordinatedProcess: CodeServerCoordinatedProcessOps; + process: CodeServerProcessOps; + runtime: CodeServerRuntime; + startupPromise: Promise | null; +} + +interface StartupFence { + attempt: number; + operationId: string; + phase: StoredCodeServerStartup["phase"]; +} + +export interface CodeServerOps { + beginCodeServerStartup: () => Promise; + codeServerStartupStatus: (operationId: string) => Promise; + continueCodeServerStartup: () => Promise; + exposeCodeServer: (input: ProjectCodeServerInput) => Promise<{ + expiresAt: string; + port: number; + url: string; + workspacePath: string; + }>; +} + +export function createCodeServerOps( + runtime: CodeServerRuntime, + dependencies: { + coordinatedProcess: CodeServerCoordinatedProcessOps; + process: CodeServerProcessOps; + }, +): CodeServerOps { + const context: CodeServerContext = { ...dependencies, runtime, startupPromise: null }; + return { + beginCodeServerStartup: () => beginCodeServerStartup(context), + codeServerStartupStatus: (operationId) => codeServerStartupStatus(context, operationId), + continueCodeServerStartup: () => continueCodeServerStartup(context), + exposeCodeServer: (input) => exposeCodeServer(context, input), + }; +} + +async function beginCodeServerStartup( + context: CodeServerContext, +): Promise { + const current = await readStartupState(context.runtime.storage); + if (current && (await canReuseStartupState(context, current))) { + await scheduleCodeServerAlarm( + context.runtime.storage, + isPendingPhase(current.phase) ? (current.nextAttemptAtMs ?? Date.now()) : null, + ); + return publicStartupStatus(current); + } + const now = Date.now(); + const state: StoredCodeServerStartup = { + attempt: 0, + operationId: crypto.randomUUID(), + phase: "queued", + requestedAtMs: now, + targetVersion: CODE_SERVER_STARTUP_VERSION, + updatedAtMs: now, + }; + await context.runtime.storage.put(CODE_SERVER_STARTUP_STATE_KEY, state); + await scheduleCodeServerAlarm(context.runtime.storage, now); + return publicStartupStatus(state); +} + +async function codeServerStartupStatus( + context: CodeServerContext, + operationId: string, +): Promise { + const parsedOperationId = z.string().uuid().parse(operationId); + const state = await readStartupState(context.runtime.storage); + if (!state || state.operationId !== parsedOperationId) { + return expiredStartupStatus(parsedOperationId); + } + if (isPendingPhase(state.phase) && Date.now() - state.updatedAtMs > STARTUP_STALE_MS) { + return expiredStartupStatus(parsedOperationId); + } + return publicStartupStatus(state); +} + +async function continueCodeServerStartup(context: CodeServerContext): Promise { + const current = await readStartupState(context.runtime.storage); + if (!current || current.phase === "ready" || current.phase === "failed") { + await scheduleCodeServerAlarm(context.runtime.storage, null); + return; + } + if (current.nextAttemptAtMs && current.nextAttemptAtMs > Date.now()) { + await scheduleCodeServerAlarm(context.runtime.storage, current.nextAttemptAtMs); + return; + } + const running = await claimStartupAttempt(context.runtime.storage, current); + if (!running) return; + try { + const sandboxId = await context.runtime.ensureSandbox(); + const preparingFiles = await prepareFilesPhase(context.runtime.storage, running, sandboxId); + if (!preparingFiles) return; + await ensureCodeServerSingleFlight(context, sandboxId); + await markCodeServerReady(context, preparingFiles, sandboxId); + } catch (error) { + await handleStartupFailure(context, running.operationId, running.attempt, error); + } +} + +async function claimStartupAttempt( + storage: DurableObjectStorage, + current: StoredCodeServerStartup, +): Promise { + if (current.phase !== "queued") return current; + if (current.attempt >= STARTUP_MAX_ATTEMPTS) { + const failed = await updateStartupState(storage, startupFence(current), { + errorCode: "internal_service_error", + phase: "failed", + retriable: true, + }); + if (failed) await scheduleCodeServerAlarm(storage, null); + return null; + } + return updateStartupState(storage, startupFence(current), { + attempt: current.attempt + 1, + phase: "starting_sandbox", + }); +} + +async function prepareFilesPhase( + storage: DurableObjectStorage, + running: StoredCodeServerStartup, + sandboxId: string, +): Promise { + if (running.phase === "starting_files") { + return updateStartupState(storage, startupFence(running), { sandboxId }); + } + return updateStartupState(storage, startupFence(running), { + phase: "starting_files", + sandboxId, + }); +} + +async function exposeCodeServer( + context: CodeServerContext, + input: ProjectCodeServerInput, +): Promise<{ expiresAt: string; port: number; url: string; workspacePath: string }> { + const parsed = ProjectCodeServerInputSchema.parse(input); + const ready = await freshReadyState(context); + const sandboxId = ready?.sandboxId ?? (await ensureAndRecordCodeServer(context)); + const displayFolder = + parsed.workspacePath === WORKSPACE_DIR + ? await ensureCodeServerDisplayFolder(context.runtime, sandboxId, parsed.workspacePath) + : parsed.workspacePath; + const built = await buildPreviewUrl({ + hostname: context.runtime.previewHostname(), + port: CODE_SERVER_PORT, + sandboxId, + secret: await context.runtime.previewSecret(), + useSubdomain: true, + }); + return { + expiresAt: built.expiresAt, + port: CODE_SERVER_PORT, + url: codeServerFolderUrl( + built.url, + displayFolder, + context.runtime.releaseSha(), + parsed.initialFilePath, + ), + workspacePath: parsed.workspacePath, + }; +} + +async function ensureAndRecordCodeServer(context: CodeServerContext): Promise { + const intent = await synchronousStartupIntent(context.runtime.storage); + try { + const sandboxId = await context.runtime.ensureSandbox(); + const preparingFiles = await prepareFilesPhase(context.runtime.storage, intent, sandboxId); + await ensureCodeServerSingleFlight(context, sandboxId); + if (preparingFiles) { + await markCodeServerReady(context, preparingFiles, sandboxId); + } + return sandboxId; + } catch (error) { + await handleStartupFailure(context, intent.operationId, intent.attempt, error); + throw error; + } +} + +async function synchronousStartupIntent( + storage: DurableObjectStorage, +): Promise { + const current = await readStartupState(storage); + if (current && isPendingPhase(current.phase)) return current; + const now = Date.now(); + const intent: StoredCodeServerStartup = { + attempt: 1, + operationId: crypto.randomUUID(), + phase: "starting_sandbox", + requestedAtMs: now, + targetVersion: CODE_SERVER_STARTUP_VERSION, + updatedAtMs: now, + }; + await storage.put(CODE_SERVER_STARTUP_STATE_KEY, intent); + return intent; +} + +function ensureCodeServerSingleFlight( + context: CodeServerContext, + sandboxId: string, +): Promise { + if (context.startupPromise !== null) return context.startupPromise; + const startup = ensureCodeServer(context, sandboxId); + const tracked = startup.finally(() => { + if (context.startupPromise === tracked) context.startupPromise = null; + }); + context.startupPromise = tracked; + return tracked; +} + +async function ensureCodeServer(context: CodeServerContext, sandboxId: string): Promise { + const [isPortReady, hasCurrentSettings] = await Promise.all([ + context.process.httpPortReady(sandboxId, CODE_SERVER_PORT, "/", 5_000), + hasCodeServerSettingsMarker(context.runtime, sandboxId), + ]); + if (isPortReady && hasCurrentSettings) return; + const tracked = await context.process.processRecord(CODE_SERVER_PROCESS_ID); + if (hasCurrentSettings && tracked?.port === CODE_SERVER_PORT) { + await relaunchTrackedCodeServer(context, sandboxId, tracked); + return; + } + if (!(await hasCodeServerRuntime(context.runtime, sandboxId))) { + throw new APIError(502, "sandbox_start_failed", "code-server is not installed", { + hint: "Start a new project sandbox from the current Daytona snapshot to use the Files viewer.", + retriable: false, + }); + } + await context.process.deleteProcessRecord(sandboxId, CODE_SERVER_PROCESS_ID); + await context.process.deleteProcessesOnPort(sandboxId, CODE_SERVER_PORT, CODE_SERVER_PROCESS_ID); + await context.runtime + .client() + .execute(sandboxId, { command: "pkill -f code-server || true", timeout: 5 }) + .catch(() => null); + await startCodeServer(context); + if (!(await context.process.httpPortReady(sandboxId, CODE_SERVER_PORT, "/", 5_000))) { + throw new APIError(502, "sandbox_start_failed", "Unable to start code-server", { + hint: "Rebuild the Daytona sandbox snapshot with code-server, then retry the Files tab.", + retriable: true, + }); + } +} + +async function relaunchTrackedCodeServer( + context: CodeServerContext, + sandboxId: string, + record: ProcessRecord, +): Promise { + const relaunched = await context.process.relaunchDevServer( + sandboxId, + CODE_SERVER_PROCESS_ID, + record, + codeServerEnvironment(context.runtime.previewHostname()), + ); + await context.process.waitForPort( + sandboxId, + CODE_SERVER_PORT, + "/", + CODE_SERVER_START_TIMEOUT_MS, + { cmdId: relaunched.cmdId, sessionId: relaunched.sessionId }, + ); +} + +async function startCodeServer(context: CodeServerContext): Promise { + await context.coordinatedProcess.startProcess({ + command: ["bash", "-lc", codeServerStartCommand()], + cwd: WORKSPACE_DIR, + env: codeServerEnvironment(context.runtime.previewHostname()), + keepAliveTimeoutMs: 0, + maxRestarts: 3, + processId: CODE_SERVER_PROCESS_ID, + restartOnFailure: true, + timeoutMs: CODE_SERVER_START_TIMEOUT_MS, + waitForPort: { + path: "/", + port: CODE_SERVER_PORT, + timeoutMs: CODE_SERVER_START_TIMEOUT_MS, + }, + }); +} + +function codeServerEnvironment(previewHostname: string): Record { + return { + CODE_SERVER_PORT: String(CODE_SERVER_PORT), + CODE_SERVER_TRUSTED_ORIGINS: codeServerTrustedOrigins(previewHostname), + CODE_SERVER_WORKSPACE: WORKSPACE_DIR, + }; +} + +async function hasCodeServerRuntime( + runtime: CodeServerRuntime, + sandboxId: string, +): Promise { + const probe = await runtime + .client() + .execute(sandboxId, { command: "command -v code-server >/dev/null", timeout: 5 }) + .catch(() => null); + return probe?.exitCode === 0; +} + +async function hasCodeServerSettingsMarker( + runtime: CodeServerRuntime, + sandboxId: string, +): Promise { + const probe = await runtime + .client() + .execute(sandboxId, { + command: `test -f ${shellQuote(CODE_SERVER_SETTINGS_MARKER)}`, + timeout: 5, + }) + .catch(() => null); + return probe?.exitCode === 0; +} + +async function ensureCodeServerDisplayFolder( + runtime: CodeServerRuntime, + sandboxId: string, + workspacePath: string, +): Promise { + const probe = await runtime + .client() + .execute(sandboxId, { + command: `ln -sfn ${shellQuote(workspacePath)} ${shellQuote(CODE_SERVER_DISPLAY_DIR)} && test -d ${shellQuote(CODE_SERVER_DISPLAY_DIR)}`, + timeout: 10, + }) + .catch(() => null); + return probe?.exitCode === 0 ? CODE_SERVER_DISPLAY_DIR : workspacePath; +} + +async function handleStartupFailure( + context: CodeServerContext, + operationId: string, + attempt: number, + error: unknown, +): Promise { + const apiError = toAPIError(error); + createLogger().warn("code_server_startup_failed", { error: apiError, operationId }); + const current = await readStartupState(context.runtime.storage); + if ( + !current || + current.operationId !== operationId || + current.attempt !== attempt || + !isPendingPhase(current.phase) + ) { + return; + } + if (apiError.retriable && attempt < STARTUP_MAX_ATTEMPTS) { + const retryAt = Date.now() + STARTUP_RETRY_DELAY_MS; + const queued = await updateStartupState(context.runtime.storage, startupFence(current), { + attempt, + errorCode: apiError.code, + nextAttemptAtMs: retryAt, + phase: "queued", + retriable: true, + }); + if (queued) await scheduleCodeServerAlarm(context.runtime.storage, retryAt); + return; + } + const failed = await updateStartupState(context.runtime.storage, startupFence(current), { + errorCode: apiError.code, + phase: "failed", + retriable: apiError.retriable, + }); + if (failed) await scheduleCodeServerAlarm(context.runtime.storage, null); +} + +async function canReuseStartupState( + context: CodeServerContext, + state: StoredCodeServerStartup, +): Promise { + if (state.targetVersion !== CODE_SERVER_STARTUP_VERSION) return false; + if (isPendingPhase(state.phase)) { + return Date.now() - state.updatedAtMs <= STARTUP_STALE_MS; + } + return state.phase === "ready" && (await isReadyEvidenceFresh(context, state)); +} + +async function freshReadyState( + context: CodeServerContext, +): Promise { + const state = await readStartupState(context.runtime.storage); + return state?.phase === "ready" && (await isReadyEvidenceFresh(context, state)) ? state : null; +} + +async function isReadyEvidenceFresh( + context: CodeServerContext, + state: StoredCodeServerStartup, +): Promise { + if ( + !state.sandboxId || + !state.processCmdId || + !state.processSessionId || + state.targetVersion !== CODE_SERVER_STARTUP_VERSION || + Date.now() - state.updatedAtMs > READY_EVIDENCE_TTL_MS + ) { + return false; + } + const [sandboxId, process] = await Promise.all([ + context.runtime.storage.get(DAYTONA_ID_KEY), + context.process.processRecord(CODE_SERVER_PROCESS_ID), + ]); + return ( + sandboxId === state.sandboxId && + process?.cmdId === state.processCmdId && + process.sessionId === state.processSessionId + ); +} + +async function markCodeServerReady( + context: CodeServerContext, + running: StoredCodeServerStartup, + sandboxId: string, +): Promise { + const process = await context.process.processRecord(CODE_SERVER_PROCESS_ID); + const ready = await updateStartupState(context.runtime.storage, startupFence(running), { + phase: "ready", + ...(process ? { processCmdId: process.cmdId, processSessionId: process.sessionId } : {}), + sandboxId, + }); + if (ready) await scheduleCodeServerAlarm(context.runtime.storage, null); +} + +async function updateStartupState( + storage: DurableObjectStorage, + fence: StartupFence, + patch: Partial, +): Promise { + const current = await readStartupState(storage); + if ( + !current || + current.operationId !== fence.operationId || + current.attempt !== fence.attempt || + current.phase !== fence.phase + ) { + return null; + } + const next = StoredCodeServerStartupSchema.parse({ + attempt: current.attempt, + operationId: current.operationId, + ...patch, + phase: patch.phase ?? current.phase, + requestedAtMs: current.requestedAtMs, + ...(patch.sandboxId === undefined && current.sandboxId ? { sandboxId: current.sandboxId } : {}), + targetVersion: current.targetVersion, + updatedAtMs: Date.now(), + }); + await storage.put(CODE_SERVER_STARTUP_STATE_KEY, next); + return next; +} + +async function readStartupState( + storage: DurableObjectStorage, +): Promise { + const raw = await storage.get(CODE_SERVER_STARTUP_STATE_KEY); + if (raw === undefined) return null; + const parsed = StoredCodeServerStartupSchema.safeParse(raw); + if (parsed.success) return parsed.data; + await storage.delete(CODE_SERVER_STARTUP_STATE_KEY); + return null; +} + +function startupFence(state: StoredCodeServerStartup): StartupFence { + return { + attempt: state.attempt, + operationId: state.operationId, + phase: state.phase, + }; +} + +function publicStartupStatus(state: StoredCodeServerStartup): SandboxIdeStartupStatus { + const retryAfterMs = isPendingPhase(state.phase) + ? Math.max(250, Math.min(10_000, (state.nextAttemptAtMs ?? Date.now() + 1_000) - Date.now())) + : undefined; + return { + ...(state.errorCode ? { errorCode: state.errorCode } : {}), + ...(state.phase === "failed" ? { message: "Files couldn't start. Try again." } : {}), + operationId: state.operationId, + phase: state.phase, + ...(state.retriable === undefined ? {} : { retriable: state.retriable }), + ...(retryAfterMs === undefined ? {} : { retryAfterMs }), + updatedAt: new Date(state.updatedAtMs).toISOString(), + }; +} + +function expiredStartupStatus(operationId: string): SandboxIdeStartupStatus { + return { + message: "This Files startup expired. Open Files again.", + operationId, + phase: "failed", + retriable: true, + updatedAt: new Date().toISOString(), + }; +} + +function isPendingPhase(phase: StoredCodeServerStartup["phase"]): boolean { + return phase === "queued" || phase === "starting_sandbox" || phase === "starting_files"; +} diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts index 4ae57e7..046ab8d 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-content.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-content.ts @@ -11,16 +11,6 @@ import type { import { encodeBase64, shellQuote } from "../sandbox-support"; import { expoPreviewEnvironment } from "./app-builder-local-preview"; import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host"; -import { - CODE_SERVER_DISPLAY_DIR, - CODE_SERVER_PORT, - CODE_SERVER_PROCESS_ID, - CODE_SERVER_SETTINGS_MARKER, - CODE_SERVER_START_TIMEOUT_MS, - codeServerFolderUrl, - codeServerStartCommand, - codeServerTrustedOrigins, -} from "./project-sandbox-code-server"; import { assertDeletableWorkspacePath, buildGrepCommand, @@ -53,8 +43,6 @@ import { ProjectBrowserTakeoverStopInputSchema, type ProjectCleanupWorkspaceInput, ProjectCleanupWorkspaceInputSchema, - type ProjectCodeServerInput, - ProjectCodeServerInputSchema, type ProjectCompareAndSwapFileInput, type ProjectDeleteFileInput, ProjectDeleteFileInputSchema, @@ -94,12 +82,6 @@ export interface ContentOps { exposeBrowserTakeover: ( input: ProjectBrowserTakeoverInput, ) => Promise; - exposeCodeServer: (input: ProjectCodeServerInput) => Promise<{ - expiresAt: string; - port: number; - url: string; - workspacePath: string; - }>; getSignedPreviewUrl: ( input: ProjectSignedPreviewUrlInput, ) => Promise<{ token: string; url: string }>; @@ -122,14 +104,11 @@ type ContentRuntime = Pick< | "ensureSandbox" | "previewHostname" | "previewSecret" - | "releaseSha" | "toUpstreamError" >; type ContentProcessOps = Pick< ProcessOps, - | "deleteProcessRecord" - | "deleteProcessesOnPort" | "freeProjectPort" | "httpPortReady" | "isPortAlive" @@ -169,7 +148,6 @@ export function createContentOps( downloadProjectArchive: (input, onFinished) => downloadProjectArchive(context, input, onFinished), exposeBrowserTakeover: (input) => exposeBrowserTakeover(context, input), - exposeCodeServer: (input) => exposeCodeServer(context, input), getSignedPreviewUrl: (input) => getSignedPreviewUrl(runtime, input), listFiles: (input) => listFiles(runtime, input), projectPreviewStatus: (input) => projectPreviewStatus(context, input), @@ -393,42 +371,6 @@ async function stopBrowserTakeover( }); } -async function exposeCodeServer( - context: ContentContext, - input: ProjectCodeServerInput, -): Promise<{ - expiresAt: string; - port: number; - url: string; - workspacePath: string; -}> { - const parsed = ProjectCodeServerInputSchema.parse(input); - const id = await context.runtime.ensureSandbox(); - await ensureCodeServer(context, id); - const displayFolder = - parsed.workspacePath === WORKSPACE_DIR - ? await ensureCodeServerDisplayFolder(context.runtime, id, parsed.workspacePath) - : parsed.workspacePath; - await context.runtime - .client() - .getPreviewLink(id, CODE_SERVER_PORT) - .catch(() => undefined); - const built = await buildPreviewUrl({ - hostname: context.runtime.previewHostname(), - port: CODE_SERVER_PORT, - sandboxId: id, - secret: await context.runtime.previewSecret(), - useSubdomain: true, - }); - const bridgeRelease = context.runtime.releaseSha(); - return { - expiresAt: built.expiresAt, - port: CODE_SERVER_PORT, - url: codeServerFolderUrl(built.url, displayFolder, bridgeRelease, parsed.initialFilePath), - workspacePath: parsed.workspacePath, - }; -} - async function wakePreview( context: ContentContext, input: ProjectWakePreviewInput, @@ -594,124 +536,6 @@ async function ensureMobileMetroForwardedHostConfig( return true; } -async function ensureCodeServer(context: ContentContext, id: string): Promise { - const [isPortReady, hasCurrentSettings] = await Promise.all([ - context.dependencies.process.httpPortReady(id, CODE_SERVER_PORT, "/", 5_000), - hasCodeServerSettingsMarker(context.runtime, id), - ]); - if (isPortReady && hasCurrentSettings) { - return; - } - const tracked = await context.dependencies.process.processRecord(CODE_SERVER_PROCESS_ID); - if (hasCurrentSettings && tracked?.port === CODE_SERVER_PORT) { - await relaunchTrackedCodeServer(context, id, tracked); - return; - } - if (!(await hasCodeServerRuntime(context.runtime, id))) { - throw new APIError(502, "sandbox_start_failed", "code-server is not installed", { - hint: "Start a new project sandbox from the current Daytona snapshot to use the Files viewer.", - retriable: false, - }); - } - await context.dependencies.process.deleteProcessRecord(id, CODE_SERVER_PROCESS_ID); - await context.dependencies.process.deleteProcessesOnPort( - id, - CODE_SERVER_PORT, - CODE_SERVER_PROCESS_ID, - ); - await context.runtime - .client() - .execute(id, { command: "pkill -f code-server || true", timeout: 5 }) - .catch(() => null); - await startCodeServer(context); - if (!(await context.dependencies.process.httpPortReady(id, CODE_SERVER_PORT, "/", 5_000))) { - throw new APIError(502, "sandbox_start_failed", "Unable to start code-server", { - hint: "Rebuild the Daytona sandbox snapshot with code-server, then retry the Files tab.", - retriable: true, - }); - } -} - -async function relaunchTrackedCodeServer( - context: ContentContext, - id: string, - record: ProcessRecord, -): Promise { - const relaunched = await context.dependencies.process.relaunchDevServer( - id, - CODE_SERVER_PROCESS_ID, - record, - codeServerEnvironment(context.runtime.previewHostname()), - ); - await context.dependencies.process.waitForPort( - id, - CODE_SERVER_PORT, - "/", - CODE_SERVER_START_TIMEOUT_MS, - { cmdId: relaunched.cmdId, sessionId: relaunched.sessionId }, - ); -} - -async function startCodeServer(context: ContentContext): Promise { - await context.dependencies.coordinatedProcess.startProcess({ - command: ["bash", "-lc", codeServerStartCommand()], - cwd: WORKSPACE_DIR, - env: codeServerEnvironment(context.runtime.previewHostname()), - keepAliveTimeoutMs: 0, - maxRestarts: 3, - processId: CODE_SERVER_PROCESS_ID, - restartOnFailure: true, - timeoutMs: CODE_SERVER_START_TIMEOUT_MS, - waitForPort: { - path: "/", - port: CODE_SERVER_PORT, - timeoutMs: CODE_SERVER_START_TIMEOUT_MS, - }, - }); -} - -function codeServerEnvironment(previewHostname: string): Record { - return { - CODE_SERVER_PORT: String(CODE_SERVER_PORT), - CODE_SERVER_TRUSTED_ORIGINS: codeServerTrustedOrigins(previewHostname), - CODE_SERVER_WORKSPACE: WORKSPACE_DIR, - }; -} - -async function hasCodeServerRuntime(runtime: ContentRuntime, id: string): Promise { - const probe = await runtime - .client() - .execute(id, { command: "command -v code-server >/dev/null", timeout: 5 }) - .catch(() => null); - return probe?.exitCode === 0; -} - -async function hasCodeServerSettingsMarker(runtime: ContentRuntime, id: string): Promise { - const probe = await runtime - .client() - .execute(id, { - command: `test -f ${shellQuote(CODE_SERVER_SETTINGS_MARKER)}`, - timeout: 5, - }) - .catch(() => null); - return probe?.exitCode === 0; -} - -async function ensureCodeServerDisplayFolder( - runtime: ContentRuntime, - id: string, - workspacePath: string, -): Promise { - const probe = await runtime - .client() - .execute(id, { - command: `ln -sfn ${shellQuote(workspacePath)} ${shellQuote(CODE_SERVER_DISPLAY_DIR)} && test -d ${shellQuote(CODE_SERVER_DISPLAY_DIR)}`, - timeout: 10, - }) - .catch(() => null); - return probe?.exitCode === 0 ? CODE_SERVER_DISPLAY_DIR : workspacePath; -} - async function removeWorkspaceFolder( runtime: ContentRuntime, id: string, diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts b/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts index 75fb802..20f3013 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-lease-policy.ts @@ -37,6 +37,7 @@ const LEASE_POLICIES = { searchFiles: ["workspace", "path"], deleteFile: ["workspace", "path"], getSignedPreviewUrl: ["sandbox"], exposeBrowserTakeover: ["sandbox"], stopBrowserTakeover: ["cleanup-signal"], + beginCodeServerStartup: ["sandbox"], codeServerStartupStatus: ["sandbox"], exposeCodeServer: ["workspace", "workspace-path"], wakePreview: ["workspace", "workspace-slug"], projectPreviewStatus: ["workspace", "workspace-slug"], diff --git a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts index 1528275..29137fa 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox-lifecycle.ts @@ -1,4 +1,5 @@ import { createLogger } from "@cheatcode/observability"; +import { scheduleKeepaliveAlarm } from "./project-sandbox-alarm"; import { DEFAULT_IDLE_STOP_MIN, KEEPALIVE_ALARM_MS, @@ -67,7 +68,7 @@ async function beginRun(runtime: LifecycleRuntime, runId: string): Promise .setAutoStopInterval(id, 0) .catch(() => undefined); await beginSandboxUsageBestEffort(await runtime.meteringContext()); - await runtime.storage.setAlarm(Date.now() + KEEPALIVE_ALARM_MS); + await scheduleKeepaliveAlarm(runtime.storage, Date.now() + KEEPALIVE_ALARM_MS); } catch (error) { await compensateFailedRunStart(runtime, runId); throw error; @@ -83,7 +84,7 @@ async function renewRun(runtime: LifecycleRuntime, runId: string): Promise const renewed = leases.filter((candidate) => candidate.runId !== runId); renewed.push({ runId, startedMs: Date.now() }); await runtime.storage.put(RUN_LEASES_KEY, renewed); - await runtime.storage.setAlarm(Date.now() + KEEPALIVE_ALARM_MS); + await scheduleKeepaliveAlarm(runtime.storage, Date.now() + KEEPALIVE_ALARM_MS); } async function endRun(runtime: LifecycleRuntime, runId: string): Promise { @@ -101,7 +102,7 @@ async function compensateFailedRunStart(runtime: LifecycleRuntime, runId: string const remaining = (await runLeases(runtime.storage)).filter((lease) => lease.runId !== runId); await runtime.storage.put(RUN_LEASES_KEY, remaining); if (remaining.length > 0) { - await runtime.storage.setAlarm(Date.now() + KEEPALIVE_ALARM_MS); + await scheduleKeepaliveAlarm(runtime.storage, Date.now() + KEEPALIVE_ALARM_MS); return; } await finalizeLastRunLease(runtime); @@ -111,17 +112,19 @@ async function compensateFailedRunStart(runtime: LifecycleRuntime, runId: string runId, sandboxId: runtime.sandboxName(), }); - await runtime.storage.setAlarm(Date.now() + KEEPALIVE_ALARM_MS).catch(() => undefined); + await scheduleKeepaliveAlarm(runtime.storage, Date.now() + KEEPALIVE_ALARM_MS).catch( + () => undefined, + ); } } async function finalizeLastRunLease(runtime: LifecycleRuntime): Promise { await finalizeSandboxUsageBestEffort(await runtime.meteringContext()); if (await restoreIdleAutoStop(runtime)) { - await runtime.storage.deleteAlarm(); + await scheduleKeepaliveAlarm(runtime.storage, null); return; } - await runtime.storage.setAlarm(Date.now() + KEEPALIVE_ALARM_MS); + await scheduleKeepaliveAlarm(runtime.storage, Date.now() + KEEPALIVE_ALARM_MS); } async function handleAlarm(runtime: LifecycleRuntime): Promise { @@ -137,7 +140,7 @@ async function handleAlarm(runtime: LifecycleRuntime): Promise { try { await recordSandboxUsageBestEffort(await runtime.meteringContext()); } finally { - await runtime.storage.setAlarm(Date.now() + KEEPALIVE_ALARM_MS); + await scheduleKeepaliveAlarm(runtime.storage, Date.now() + KEEPALIVE_ALARM_MS); } } diff --git a/apps/agent-worker/src/durable-objects/project-sandbox.ts b/apps/agent-worker/src/durable-objects/project-sandbox.ts index 4c1a5ac..d2469bf 100644 --- a/apps/agent-worker/src/durable-objects/project-sandbox.ts +++ b/apps/agent-worker/src/durable-objects/project-sandbox.ts @@ -15,7 +15,13 @@ import type { ProjectFile, ProjectFileUploadResponse, SandboxConsoleSnapshot, + SandboxIdeStartupStatus, } from "@cheatcode/types/api"; +import { + projectSandboxAlarmTasksDue, + rescheduleProjectSandboxAlarm, +} from "./project-sandbox-alarm"; +import { type CodeServerOps, createCodeServerOps } from "./project-sandbox-code-server-runtime"; import { type ContentOps, createContentOps } from "./project-sandbox-content"; import { createGeneratedOutputOps, @@ -67,7 +73,8 @@ type ProjectSandboxOperations = LifecycleOps & ProcessOps & FileOps & ContentOps & - GeneratedOutputOps; + GeneratedOutputOps & + CodeServerOps; /** * Public Durable Object facade. The policy table is interpreted only here; @@ -86,6 +93,10 @@ export class ProjectSandbox extends DurableObject { let process: ProcessOps; const coordinated = this.coordinatedProcessOps(() => process); process = createProcessOps(this.runtime, coordinated); + const codeServer = createCodeServerOps(this.runtime, { + coordinatedProcess: coordinated, + process, + }); const files = createFileOps(this.runtime); const generatedOutputs = createGeneratedOutputOps(this.runtime); const content = createContentOps(this.runtime, { @@ -95,7 +106,14 @@ export class ProjectSandbox extends DurableObject { sandboxRuntimeState: () => this.withLease("sandboxRuntimeState", undefined, lifecycle.sandboxRuntimeState), }); - this.operations = { ...lifecycle, ...process, ...files, ...content, ...generatedOutputs }; + this.operations = { + ...lifecycle, + ...process, + ...files, + ...content, + ...generatedOutputs, + ...codeServer, + }; } // Account deletion fences and drains active operations; taking a lease would deadlock. @@ -128,7 +146,23 @@ export class ProjectSandbox extends DurableObject { } public override alarm(): Promise { - return this.withLease("alarm", undefined, this.operations.alarm); + return this.withLease("alarm", undefined, async () => { + const tasks = await projectSandboxAlarmTasksDue(this.runtime.storage); + const errors: unknown[] = []; + if (tasks.keepalive) { + await this.operations.alarm().catch((error: unknown) => errors.push(error)); + } + if (tasks.codeServer) { + await this.operations + .continueCodeServerStartup() + .catch((error: unknown) => errors.push(error)); + } + await rescheduleProjectSandboxAlarm(this.runtime.storage).catch((error: unknown) => + errors.push(error), + ); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, "Project sandbox alarm tasks failed"); + }); } public runtimeSandboxId(): Promise { @@ -285,6 +319,18 @@ export class ProjectSandbox extends DurableObject { return this.withLease("exposeCodeServer", input, () => this.operations.exposeCodeServer(input)); } + public beginCodeServerStartup(): Promise { + return this.withLease("beginCodeServerStartup", undefined, () => + this.operations.beginCodeServerStartup(), + ); + } + + public codeServerStartupStatus(operationId: string): Promise { + return this.withLease("codeServerStartupStatus", operationId, () => + this.operations.codeServerStartupStatus(operationId), + ); + } + public wakePreview(input: ProjectWakePreviewInput): Promise { return this.withLease("wakePreview", input, () => this.operations.wakePreview(input)); } diff --git a/apps/agent-worker/src/sandbox-preview-http-routes.ts b/apps/agent-worker/src/sandbox-preview-http-routes.ts index 2de7407..f75b75f 100644 --- a/apps/agent-worker/src/sandbox-preview-http-routes.ts +++ b/apps/agent-worker/src/sandbox-preview-http-routes.ts @@ -1,6 +1,8 @@ import { workspacePathForSlug } from "@cheatcode/db"; import { SandboxIdeSessionSchema, + type SandboxIdeStartupStatus, + SandboxIdeStartupStatusSchema, SandboxPreviewStatusSchema, SandboxPreviewWakeSchema, } from "@cheatcode/types/api"; @@ -21,7 +23,11 @@ type AgentContext = Context<{ Bindings: AgentEnv }>; export function registerSandboxPreviewHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { const routes = AGENT_FORWARD_ROUTES.piped; app.on(routes.computerIde.method, routes.computerIde.path, openComputerIde); + app.on(routes.computerIdeOpen.method, routes.computerIdeOpen.path, beginComputerIde); + app.on(routes.computerIdeStatus.method, routes.computerIdeStatus.path, computerIdeStatus); app.on(routes.sandboxIde.method, routes.sandboxIde.path, openThreadIde); + app.on(routes.sandboxIdeOpen.method, routes.sandboxIdeOpen.path, beginThreadIde); + app.on(routes.sandboxIdeStatus.method, routes.sandboxIdeStatus.path, threadIdeStatus); app.on(routes.sandboxPreviewWake.method, routes.sandboxPreviewWake.path, wakeThreadPreview); app.on(routes.sandboxPreviewStatus.method, routes.sandboxPreviewStatus.path, threadPreviewStatus); } @@ -33,6 +39,20 @@ async function openComputerIde(c: AgentContext): Promise { return ideSessionResponse(c, session); } +async function beginComputerIde(c: AgentContext): Promise { + const userId = readGatewayUserId(c.req.raw.headers); + const sandbox = await sandboxForUser(c.env, userId); + const status = await sandbox.beginCodeServerStartup(); + return ideStartupResponse(c, status, computerIdeStatusPath(status.operationId)); +} + +async function computerIdeStatus(c: AgentContext): Promise { + const userId = readGatewayUserId(c.req.raw.headers); + const sandbox = await sandboxForUser(c.env, userId); + const status = await sandbox.codeServerStartupStatus(c.req.param("operationId") ?? ""); + return ideStatusResponse(c, status); +} + async function openThreadIde(c: AgentContext): Promise { const userId = readGatewayUserId(c.req.raw.headers); const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); @@ -45,6 +65,23 @@ async function openThreadIde(c: AgentContext): Promise { return ideSessionResponse(c, session); } +async function beginThreadIde(c: AgentContext): Promise { + const userId = readGatewayUserId(c.req.raw.headers); + const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); + await requireWritableThreadProject(c.env, userId, threadId); + const sandbox = await sandboxForUser(c.env, userId); + const status = await sandbox.beginCodeServerStartup(); + return ideStartupResponse(c, status, threadIdeStatusPath(threadId, status.operationId)); +} + +async function threadIdeStatus(c: AgentContext): Promise { + const userId = readGatewayUserId(c.req.raw.headers); + parseThreadRouteParam(c.req.param("threadId") ?? ""); + const sandbox = await sandboxForUser(c.env, userId); + const status = await sandbox.codeServerStartupStatus(c.req.param("operationId") ?? ""); + return ideStatusResponse(c, status); +} + async function wakeThreadPreview(c: AgentContext): Promise { const userId = readGatewayUserId(c.req.raw.headers); const threadId = parseThreadRouteParam(c.req.param("threadId") ?? ""); @@ -90,3 +127,29 @@ function ideSessionResponse( }), ); } + +function ideStartupResponse( + c: AgentContext, + status: SandboxIdeStartupStatus, + statusPath: string, +): Response { + c.header("Cache-Control", PRIVATE_CAPABILITY_CACHE_CONTROL); + c.header("Location", statusPath); + const parsed = SandboxIdeStartupStatusSchema.parse(status); + if (parsed.phase === "ready") return c.json(parsed); + c.header("Retry-After", "1"); + return c.json(parsed, 202); +} + +function ideStatusResponse(c: AgentContext, status: SandboxIdeStartupStatus): Response { + c.header("Cache-Control", PRIVATE_CAPABILITY_CACHE_CONTROL); + return c.json(SandboxIdeStartupStatusSchema.parse(status)); +} + +function computerIdeStatusPath(operationId: string): string { + return `/v1/computer/ide/status/${encodeURIComponent(operationId)}`; +} + +function threadIdeStatusPath(threadId: string, operationId: string): string { + return `/v1/threads/${encodeURIComponent(threadId)}/sandbox/ide/status/${encodeURIComponent(operationId)}`; +} diff --git a/apps/web/README.md b/apps/web/README.md index fc865a5..63aad45 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -59,6 +59,13 @@ the same mark in place until the final document has loaded. Preview wake and cap paused during fresh scaffold generation; readiness acquires a new one-minute handoff immediately before the iframe mounts and exchanges it for the renewable ten-minute host cookie. Existing project edits remain visible and continue hot-reloading normally. +Fresh app-builder project creation selects Browser and commits the local `building` state in the +same client update before opening Computer. That closes the gap where Files used to become the +temporary default and where Browser could wake before the streamed build status arrived. An +intentional Files open now posts a durable startup request, polls its lightweight authenticated +status with cancellation, displays the current preparation phase, and requests the iframe session +only after code-server is ready. The previous session GET remains the final capability-minting step, +so no preview credential enters query storage or client persistence. When the agent completes a browser-open action for an app-builder project, the Browser panel requests one authenticated reload so the user sees the same freshly verified build. The preview owner first renews the short-lived handoff capability and only then remounts the entry document; an old capability diff --git a/apps/web/src/components/chat/chat-panel-controller.ts b/apps/web/src/components/chat/chat-panel-controller.ts index c103737..64d3e4f 100644 --- a/apps/web/src/components/chat/chat-panel-controller.ts +++ b/apps/web/src/components/chat/chat-panel-controller.ts @@ -2,7 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { CHEATCODE_DATA_SCHEMAS, type CheatcodeUIMessage } from "@cheatcode/types"; -import type { ProjectSummary, Thread } from "@cheatcode/types/api"; +import type { ProjectMode, ProjectSummary, Thread } from "@cheatcode/types/api"; import { useAuth } from "@clerk/nextjs"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { ChatOnDataCallback, ChatStatus } from "ai"; @@ -458,7 +458,12 @@ function handleProjectCreatedData( ): void { const parsed = CHEATCODE_DATA_SCHEMAS["project-created"].safeParse(data); if (parsed.success) { - handleProjectCreated(parsed.data.projectId, input); + handleProjectCreated( + parsed.data.projectId, + parsed.data.projectMode, + parsed.data.previewBuilding === true, + input, + ); } } @@ -478,12 +483,19 @@ function handleSkillCreatedData( function handleProjectCreated( projectId: string, + projectMode: ProjectMode | undefined, + previewBuilding: boolean, input: Parameters[0], ): void { input.queryClient.setQueryData(threadKeys.detail(input.threadId), (thread) => thread ? { ...thread, projectId } : thread, ); - input.sandboxActions.setActiveComputerTab("files"); + input.sandboxActions.setActiveComputerTab( + projectMode === "app-builder" || projectMode === "app-builder-mobile" ? "browser" : "files", + ); + if (previewBuilding) { + input.sandboxActions.setAppPreviewStatus("building"); + } input.sandboxActions.setPreviewPanelOpen(true); for (const queryKey of [threadKeys.detail(input.threadId), projectKeys.detail(projectId)]) { void input.queryClient.invalidateQueries({ queryKey }); diff --git a/apps/web/src/components/chat/use-computer-view-sync.ts b/apps/web/src/components/chat/use-computer-view-sync.ts index 938affa..73e2da1 100644 --- a/apps/web/src/components/chat/use-computer-view-sync.ts +++ b/apps/web/src/components/chat/use-computer-view-sync.ts @@ -295,6 +295,13 @@ function applyComputerViewCommand( if (useAppStore.getState().appPreviewStatus !== command.status) { actions.setAppPreviewStatus(command.status); } + if ( + command.status === "building" && + (projectMode === "app-builder" || projectMode === "app-builder-mobile") + ) { + actions.setActiveComputerTab("browser"); + actions.setPreviewPanelOpen(true); + } return; } if (!shouldApplyComputerViewCommand(command, projectMode)) { diff --git a/apps/web/src/components/preview/sandbox-ide-tab.tsx b/apps/web/src/components/preview/sandbox-ide-tab.tsx index 364aa9e..9effcf3 100644 --- a/apps/web/src/components/preview/sandbox-ide-tab.tsx +++ b/apps/web/src/components/preview/sandbox-ide-tab.tsx @@ -1,5 +1,6 @@ "use client"; +import type { SandboxIdeStartupPhase } from "@cheatcode/types/api"; import { useAuth } from "@clerk/nextjs"; import { useQuery } from "@tanstack/react-query"; import { useTheme } from "next-themes"; @@ -76,13 +77,16 @@ function ActiveSandboxIdeTab({ const iframeUrl = useStablePreviewSource(requestedIframeUrl); const bridge = useCodeServerBridge(iframeUrl, threadId); const refetchSession = () => void ideQuery.refetch(); - const reloadFrame = () => setFrameReloadToken((current) => current + 1); + const recoverFrame = () => { + setFrameReloadToken((current) => current + 1); + void ideQuery.refetch(); + }; return ( @@ -90,13 +94,14 @@ function ActiveSandboxIdeTab({ } function useSandboxIdeQuery(threadId: string | null, getToken: () => Promise) { + const [startupPhase, setStartupPhase] = useState("queued"); // Files resolves either the per-user computer root or the active project folder. - return useQuery({ + const query = useQuery({ gcTime: 0, queryFn: ({ signal }) => threadId === null - ? openComputerIde(getToken, signal) - : openSandboxIde(getToken, threadId, signal), + ? openComputerIde(getToken, { onPhase: setStartupPhase, signal }) + : openSandboxIde(getToken, threadId, { onPhase: setStartupPhase, signal }), queryKey: ["sandbox-ide", threadId ?? "computer"], refetchInterval: (query) => (query?.state.fetchFailureCount ?? 0) > 0 ? 60_000 : PREVIEW_SESSION_REFRESH_MS, @@ -105,6 +110,7 @@ function useSandboxIdeQuery(threadId: string | null, getToken: () => Promise; + return ; } if (ideQuery.isError) { return ; } if (!iframeUrl) { - return ; + return ; } return ( ; +function IdePlaceholder({ phase }: { phase: SandboxIdeStartupPhase }) { + const copy = ideStartupCopy(phase); + return ( +
+
+ +
+

{copy.title}

+

{copy.description}

+
+
+
+ ); +} + +function ideStartupCopy(phase: SandboxIdeStartupPhase): { + description: string; + title: string; +} { + if (phase === "queued") { + return { description: "This usually takes a few seconds.", title: "Preparing your computer" }; + } + if (phase === "starting_sandbox") { + return { description: "Your workspace is waking up.", title: "Starting your computer" }; + } + if (phase === "starting_files") { + return { + description: "Your files will appear as soon as they're ready.", + title: "Opening Files", + }; + } + return { description: "Connecting to your workspace.", title: "Opening Files" }; } function IdeError({ isRetrying, onRetry }: { isRetrying: boolean; onRetry: () => void }) { diff --git a/apps/web/src/lib/api/authorized-fetch.ts b/apps/web/src/lib/api/authorized-fetch.ts index 410bea4..8ffe55b 100644 --- a/apps/web/src/lib/api/authorized-fetch.ts +++ b/apps/web/src/lib/api/authorized-fetch.ts @@ -18,6 +18,16 @@ export interface AuthorizedFetchOptions { timeoutMs?: number; } +export class AuthorizedFetchError extends Error { + public readonly status: number; + + public constructor(status: number, message: string) { + super(message); + this.name = "AuthorizedFetchError"; + this.status = status; + } +} + export const API_RESPONSE_LIMIT_BYTES = { archive: PROJECT_ARCHIVE_MAX_OUTPUT_BYTES, archiveFallback: 64 * MEBIBYTE, @@ -58,7 +68,7 @@ export async function authorizedFetch( signal, }); if (!response.ok) { - throw new Error(await readErrorMessage(response)); + throw new AuthorizedFetchError(response.status, await readErrorMessage(response)); } return response; } diff --git a/apps/web/src/lib/api/sandbox.ts b/apps/web/src/lib/api/sandbox.ts index 37284f4..2274f06 100644 --- a/apps/web/src/lib/api/sandbox.ts +++ b/apps/web/src/lib/api/sandbox.ts @@ -11,6 +11,9 @@ import { SandboxConsoleSnapshotSchema, type SandboxIdeSession, SandboxIdeSessionSchema, + type SandboxIdeStartupPhase, + type SandboxIdeStartupStatus, + SandboxIdeStartupStatusSchema, SandboxTerminalCommandSchema, type SandboxTerminalContext, SandboxTerminalContextSchema, @@ -20,6 +23,7 @@ import { import { API_REQUEST_TIMEOUT_MS, API_RESPONSE_LIMIT_BYTES, + AuthorizedFetchError, authorizedFetch, readBoundedJsonResponse, } from "@/lib/api/authorized-fetch"; @@ -90,17 +94,9 @@ export async function readSandboxTerminalContext( export async function openSandboxIde( getToken: () => Promise, threadId: string, - signal?: AbortSignal, + options: SandboxIdeOpenOptions = {}, ): Promise { - const response = await authorizedFetch( - getToken, - sandboxIdePath(threadId), - signal ? { signal } : {}, - { timeoutMs: API_REQUEST_TIMEOUT_MS.provisioning }, - ); - return SandboxIdeSessionSchema.parse( - await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.sandboxMetadata), - ); + return openPreparedIde(getToken, sandboxIdePath(threadId), options); } export async function readBrowserTakeoverStatus( @@ -152,9 +148,83 @@ export async function resumeBrowserAutomation( export async function openComputerIde( getToken: () => Promise, + options: SandboxIdeOpenOptions = {}, +): Promise { + return openPreparedIde(getToken, "/v1/computer/ide", options); +} + +export interface SandboxIdeOpenOptions { + onPhase?: (phase: SandboxIdeStartupPhase) => void; + signal?: AbortSignal; +} + +async function openPreparedIde( + getToken: () => Promise, + path: string, + options: SandboxIdeOpenOptions, +): Promise { + let status = await beginIdeStartup(getToken, path, options.signal); + if (status === null) { + return readIdeSession(getToken, path, options.signal); + } + options.onPhase?.(status.phase); + while (isIdeStartupPending(status)) { + await waitForIdePoll(status.retryAfterMs ?? 1_000, options.signal); + status = await readIdeStartupStatus(getToken, path, status.operationId, options.signal); + options.onPhase?.(status.phase); + } + if (status.phase === "failed") { + throw new Error(status.message ?? "Files couldn't start. Try again."); + } + return readIdeSession(getToken, path, options.signal); +} + +async function beginIdeStartup( + getToken: () => Promise, + path: string, + signal?: AbortSignal, +): Promise { + try { + const response = await authorizedFetch( + getToken, + `${path}/open`, + { method: "POST", ...(signal ? { signal } : {}) }, + { timeoutMs: API_REQUEST_TIMEOUT_MS.provisioning }, + ); + return SandboxIdeStartupStatusSchema.parse( + await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.sandboxMetadata), + ); + } catch (error) { + if (error instanceof AuthorizedFetchError && (error.status === 404 || error.status === 405)) { + return null; + } + throw error; + } +} + +async function readIdeStartupStatus( + getToken: () => Promise, + path: string, + operationId: string, + signal?: AbortSignal, +): Promise { + const response = await authorizedFetch( + getToken, + `${path}/status/${encodeURIComponent(operationId)}`, + signal ? { signal } : {}, + { timeoutMs: API_REQUEST_TIMEOUT_MS.provisioning }, + ); + return SandboxIdeStartupStatusSchema.parse( + await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.sandboxMetadata), + ); +} + +async function readIdeSession( + getToken: () => Promise, + path: string, signal?: AbortSignal, ): Promise { - const response = await authorizedFetch(getToken, "/v1/computer/ide", signal ? { signal } : {}, { + const response = await authorizedFetch(getToken, path, signal ? { signal } : {}, { timeoutMs: API_REQUEST_TIMEOUT_MS.provisioning, }); return SandboxIdeSessionSchema.parse( @@ -162,6 +232,29 @@ export async function openComputerIde( ); } +function isIdeStartupPending(status: SandboxIdeStartupStatus): boolean { + return ( + status.phase === "queued" || + status.phase === "starting_sandbox" || + status.phase === "starting_files" + ); +} + +function waitForIdePoll(delayMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => { + window.clearTimeout(timeout); + reject(signal?.reason); + }; + const timeout = window.setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + export async function readComputerTerminalContext( getToken: () => Promise, signal?: AbortSignal, diff --git a/packages/types/README.md b/packages/types/README.md index 6e68c44..bbfea58 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -33,8 +33,9 @@ capability discovery contracts, error codes, and UI message types. The `./api` subpath also exports the canonical user-message character budget, project-file upload/batch/namespace limits and schemas, the discriminated upload/generated-Deliverable project file catalog plus deterministic Deliverable path builder, and finalized project-archive byte -limit so browser and Worker boundaries cannot drift. `CreateRunSchema` keeps the exact user message -separate from validated non-app run intent, selected-skill, and connected-app metadata. +limit so browser and Worker boundaries cannot drift. It also owns the bounded Files-startup phase +contract used by the web client and ProjectSandbox status routes. `CreateRunSchema` keeps the exact +user message separate from validated non-app run intent, selected-skill, and connected-app metadata. ## Code Checks diff --git a/packages/types/src/agent-forward-routes.ts b/packages/types/src/agent-forward-routes.ts index ba1b0c3..7cb1dae 100644 --- a/packages/types/src/agent-forward-routes.ts +++ b/packages/types/src/agent-forward-routes.ts @@ -52,6 +52,16 @@ export const AGENT_FORWARD_ROUTES = { path: "/v1/computer/ide", rateLimitCost: 5, }, + computerIdeOpen: { + method: "POST", + path: "/v1/computer/ide/open", + rateLimitCost: 3, + }, + computerIdeStatus: { + method: "GET", + path: "/v1/computer/ide/status/:operationId", + rateLimitCost: 1, + }, computerTerminal: { method: "POST", path: "/v1/computer/terminal", @@ -77,6 +87,16 @@ export const AGENT_FORWARD_ROUTES = { path: "/v1/threads/:threadId/sandbox/ide", rateLimitCost: 5, }, + sandboxIdeOpen: { + method: "POST", + path: "/v1/threads/:threadId/sandbox/ide/open", + rateLimitCost: 3, + }, + sandboxIdeStatus: { + method: "GET", + path: "/v1/threads/:threadId/sandbox/ide/status/:operationId", + rateLimitCost: 1, + }, sandboxPreviewStatus: { method: "GET", path: "/v1/threads/:threadId/sandbox/preview/status", diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 9981646..8d7ddb0 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -1,10 +1,14 @@ import { z } from "zod"; import { OutputIdSchema } from "./artifacts"; +import { ErrorCodeSchema } from "./errors"; import { IntegrationNameSchema } from "./integrations"; import { LogicalModelIdSchema } from "./models"; +import { ProjectModeSchema } from "./project-mode"; import { extendSandboxExecResultShape, sandboxFileEntryShape } from "./sandbox-wire"; import { MessagePartsSchema } from "./ui-message"; +export { ProjectModeSchema } from "./project-mode"; + /** Canonical total character budget for one submitted user message, including inline attachments. */ export const USER_MESSAGE_MAX_CHARACTERS = 20_000; @@ -31,9 +35,6 @@ export const GitHubRepoUrlSchema = z "Must be a public https://github.com/{owner}/{repo} URL", ); -const PROJECT_MODES = ["app-builder", "app-builder-mobile", "general"] as const; -export const ProjectModeSchema = z.enum(PROJECT_MODES); - /** Explicit non-app work paths selected by the composer; app topology remains a project mode. */ const RUN_INTENTS = ["data", "documents", "media", "research", "skill-creator", "slides"] as const; export const RunIntentSchema = z.enum(RUN_INTENTS); @@ -363,6 +364,24 @@ export const SandboxIdeSessionSchema = z.strictObject({ workspacePath: SandboxFilePathSchema, }); +export const SandboxIdeStartupPhaseSchema = z.enum([ + "queued", + "starting_sandbox", + "starting_files", + "ready", + "failed", +]); + +export const SandboxIdeStartupStatusSchema = z.strictObject({ + errorCode: ErrorCodeSchema.optional(), + message: z.string().min(1).max(300).optional(), + operationId: z.string().uuid(), + phase: SandboxIdeStartupPhaseSchema, + retriable: z.boolean().optional(), + retryAfterMs: z.number().int().min(250).max(10_000).optional(), + updatedAt: z.string().datetime(), +}); + const BrowserTakeoverActiveSchema = z.strictObject({ expiresAt: z.string().datetime(), status: z.literal("active"), @@ -632,6 +651,8 @@ export type SandboxConsoleProcess = z.infer; export type SandboxConsoleSnapshot = z.infer; export type SandboxFileEntry = z.infer; export type SandboxIdeSession = z.infer; +export type SandboxIdeStartupPhase = z.infer; +export type SandboxIdeStartupStatus = z.infer; export type BrowserTakeoverStatus = z.infer; export type BrowserTakeoverSession = z.infer; export type SandboxPreviewWake = z.infer; diff --git a/packages/types/src/project-mode.ts b/packages/types/src/project-mode.ts new file mode 100644 index 0000000..0122e9c --- /dev/null +++ b/packages/types/src/project-mode.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +const PROJECT_MODES = ["app-builder", "app-builder-mobile", "general"] as const; +export const ProjectModeSchema = z.enum(PROJECT_MODES); diff --git a/packages/types/src/ui-message.ts b/packages/types/src/ui-message.ts index c1a583b..15c4774 100644 --- a/packages/types/src/ui-message.ts +++ b/packages/types/src/ui-message.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ArtifactKindSchema, OutputIdSchema } from "./artifacts"; import type { AgentRunId, UserId } from "./ids"; import { type LogicalModelId, LogicalModelIdSchema } from "./models"; +import { ProjectModeSchema } from "./project-mode"; const TaskStatusSchema = z.enum(["pending", "running", "completed", "failed"]); const SandboxStreamStatusSchema = z.enum(["starting", "ready", "failed"]); @@ -63,7 +64,9 @@ const AppPreviewStatusDataSchema = z.strictObject({ const ProjectCreatedDataSchema = z.strictObject({ v: z.literal(1), projectId: z.string().uuid(), + projectMode: ProjectModeSchema.optional(), projectName: z.string().min(1).max(200), + previewBuilding: z.boolean().optional(), }); const SkillCreatedDataSchema = z.strictObject({