diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 98b682304..2d4b593df 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -235,9 +235,10 @@ directly to MinIO for presigned artifact uploads and downloads, and sends all other paths, including web auth, browser tRPC, Slack OAuth, and UI routes, to `web:3000`. -After those values are in place, use the admin **Settings → Live Previews** -page to confirm the detected runtime config, validate the wildcard preview -hostname, and opt previews in at the deployment and environment levels. +After those values are in place, open any task's preview pane as an admin to +confirm the detected runtime config and validate the wildcard preview +hostname. Live previews are always enabled: they publish for every environment +that defines preview ports once the runtime config is ready. ## Model Provider @@ -522,7 +523,7 @@ the queue workers keep running. `PREVIEW_PROXY_BASE_URL` and `PREVIEW_DOMAINS` no longer block startup when unset: live previews simply report as not configured until you set the values -via env or from **Settings → Live Previews**. +via env or from the preview pane's admin setup on any task page. `SETUP_TOKEN` protects the pre-auth `/setup` bootstrap wizard, which admits the deployment's first admin. Until initial setup completes, the wizard rejects diff --git a/apps/controller/package.json b/apps/controller/package.json index 288f301a4..ac2c6adb6 100644 --- a/apps/controller/package.json +++ b/apps/controller/package.json @@ -22,7 +22,6 @@ "@roomote/compute-providers": "workspace:^", "@roomote/db": "workspace:^", "@roomote/env": "workspace:^", - "@roomote/feature-flags": "workspace:^", "@roomote/redis": "workspace:^", "@roomote/sdk": "workspace:^", "@roomote/types": "workspace:^", diff --git a/apps/controller/src/__tests__/utils.test.ts b/apps/controller/src/__tests__/utils.test.ts index 467caea6b..0e1721029 100644 --- a/apps/controller/src/__tests__/utils.test.ts +++ b/apps/controller/src/__tests__/utils.test.ts @@ -150,11 +150,6 @@ describe('getNamedPortsForTaskRun', () => { }); it('exposes configured preview ports', async () => { - mockDeploymentSettingsFindFirst.mockResolvedValueOnce({ - metadata: { - previews_enabled: true, - }, - }); vi.mocked(db.query.environments.findFirst).mockResolvedValue({ id: 'env-123', config: mockEnvironmentConfig({ @@ -183,7 +178,7 @@ describe('getNamedPortsForTaskRun', () => { ]); }); - it('does not expose configured preview ports when deployment previews are disabled', async () => { + it('exposes configured preview ports even when stale deployment metadata disables previews', async () => { mockDeploymentSettingsFindFirst.mockResolvedValueOnce({ metadata: { previews_enabled: false, @@ -208,13 +203,13 @@ describe('getNamedPortsForTaskRun', () => { const result = await getNamedPortsForTaskRun(taskRun); - expect(result.namedPorts).toEqual([SANDBOX_SERVER_NAMED_PORT]); - expect(result.environmentConfig?.ports).toEqual([ + expect(result.namedPorts).toEqual([ + SANDBOX_SERVER_NAMED_PORT, { name: 'WEB', port: 3000 }, ]); }); - it('does not expose configured preview ports when the environment disables previews', async () => { + it('exposes configured preview ports even when the environment config contains the deprecated previews_enabled: false', async () => { vi.mocked(db.query.environments.findFirst).mockResolvedValue({ id: 'env-123', config: mockEnvironmentConfig({ @@ -235,11 +230,41 @@ describe('getNamedPortsForTaskRun', () => { const result = await getNamedPortsForTaskRun(taskRun); + expect(result.namedPorts).toEqual([ + SANDBOX_SERVER_NAMED_PORT, + { name: 'WEB', port: 3000 }, + ]); + }); + + it('does not expose configured preview ports when the preview runtime config is not ready', async () => { + mockResolveEffectivePreviewRuntimeConfig.mockResolvedValueOnce({ + analysis: { + isReady: false, + }, + }); + vi.mocked(db.query.environments.findFirst).mockResolvedValue({ + id: 'env-123', + config: mockEnvironmentConfig({ + ports: [{ name: 'WEB', port: 3000 }], + }), + snapshotId: null, + snapshotStatus: null, + snapshotExpiresAt: null, + } as unknown as Awaited< + ReturnType + >); + + const taskRun = { + id: 123, + payload: { environmentId: 'env-123' }, + } as Parameters[0]; + + const result = await getNamedPortsForTaskRun(taskRun); + expect(result.namedPorts).toEqual([SANDBOX_SERVER_NAMED_PORT]); expect(result.environmentConfig?.ports).toEqual([ { name: 'WEB', port: 3000 }, ]); - expect(result.environmentConfig?.previews_enabled).toBe(false); }); it('does not expose callback ports from loopback controller URLs', async () => { diff --git a/apps/controller/src/utils.ts b/apps/controller/src/utils.ts index af157c1f0..7e89bc660 100644 --- a/apps/controller/src/utils.ts +++ b/apps/controller/src/utils.ts @@ -6,10 +6,6 @@ import { type NamedPort, resolveComputeProviderTarget, } from '@roomote/types'; -import { - areDeploymentPreviewsEnabled, - normalizeMetadataRecord, -} from '@roomote/feature-flags'; import { type TaskRun, type DatabaseOrTransaction, @@ -33,10 +29,6 @@ interface NamedPortsResult { environmentConfig?: EnvironmentConfig; } -interface DeploymentRuntimeFlags { - livePreviewsEnabled: boolean; -} - type ShouldEnableAuthBypassForTaskRunParams = { environmentConfig?: EnvironmentConfig; namedPorts: NamedPort[]; @@ -56,25 +48,14 @@ function configuredPreviewPortNeedsAuthBypass(port: NamedPort): boolean { return requiresPreviewAuth(port) && port.proxied !== false; } -async function resolveDeploymentRuntimeFlags(): Promise { - const settings = await db.query.deploymentSettings.findFirst({ - columns: { - metadata: true, - }, - }); - - const metadata = normalizeMetadataRecord(settings?.metadata); +async function isPreviewRuntimeReady(): Promise { const previewRuntimeConfig = await resolveEffectivePreviewRuntimeConfig({ runtimeEnv: process.env, defaultPreviewProxyBaseUrl: Env.PREVIEW_PROXY_BASE_URL, defaultPreviewDomains: Env.PREVIEW_DOMAINS, }); - return { - livePreviewsEnabled: - areDeploymentPreviewsEnabled(metadata) && - previewRuntimeConfig.analysis.isReady, - }; + return previewRuntimeConfig.analysis.isReady; } export function shouldEnableAuthBypassForTaskRun({ @@ -122,14 +103,10 @@ export async function getNamedPortsForTaskRun( if (environment) { environmentConfig = environment.config; - const deploymentRuntimeFlags = await resolveDeploymentRuntimeFlags(); + const previewRuntimeReady = await isPreviewRuntimeReady(); namedPorts = getNamedPortsForEnvironment({ - ports: - deploymentRuntimeFlags.livePreviewsEnabled && - environmentConfig.previews_enabled !== false - ? environmentConfig.ports - : undefined, + ports: previewRuntimeReady ? environmentConfig.ports : undefined, }); // Check if environment has a ready snapshot we can use. diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 003ba8c68..f49697575 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -31,7 +31,7 @@ The exact order depends on the setting. A few important cases: - Model role env vars such as `R_MODEL` override the same role selected in **Settings > Models**. - Preview env vars such as `PREVIEW_PROXY_BASE_URL` override values saved from - **Settings > Live Previews**. + the preview pane's admin setup on the task page. - Sandbox provider credentials resolve as process env first, then saved deployment env vars. The default sandbox provider is special: an explicit admin selection saved in Roomote wins over `DEFAULT_COMPUTE_PROVIDER` because @@ -331,7 +331,7 @@ apply semantics. | Env var | Required | Used for | | --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | -| `PREVIEW_PROXY_BASE_URL` | Preview support | Public preview-proxy origin. Overrides **Settings > Live Previews** when set. | +| `PREVIEW_PROXY_BASE_URL` | Preview support | Public preview-proxy origin. Overrides the origin saved from the preview pane's admin setup when set. | | `PREVIEW_DOMAINS` | Optional | Comma-separated preview domains allowed by the preview proxy. Usually derived from `PREVIEW_PROXY_BASE_URL`. | | `ROOMOTE_PREVIEW_DOMAIN` | Optional | Roomote preview domain override used by preview runtime analysis. | | `PREVIEW_TOKEN_TTL_SECONDS` | Optional | Preview auth token lifetime. Defaults to 3600 seconds. | diff --git a/apps/docs/environments/definition.mdx b/apps/docs/environments/definition.mdx index 1922ede25..50a997d8e 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -152,7 +152,6 @@ agentInstructions: | | `services` | list | no | Managed services to start. See [Services](#services). | | `docker_projects` | list | no | Existing Compose or Dockerfile projects to build and start. See [Docker projects](#docker-projects). | | `ports` | list | no | Named preview ports. See [Ports](#ports). | -| `previews_enabled` | boolean | no | Explicitly enable or disable live previews without deleting port definitions. | | `oidc` | map | no | Sandbox OIDC targets. See [OIDC](#oidc). | | `mcpServers` | map | no | Custom MCP servers for this environment. See [MCP servers](#mcp-servers). | | `skills` | map | no | Installable skills by `owner/repo`. See [Skills](#skills). | diff --git a/apps/web/src/app/(authenticated)/settings/previews/page.tsx b/apps/web/src/app/(authenticated)/settings/previews/page.tsx index 44d938677..8a17c7ee5 100644 --- a/apps/web/src/app/(authenticated)/settings/previews/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/previews/page.tsx @@ -1,5 +1,9 @@ -import { LivePreviewsSettingsPage } from '@/components/settings/pages/LivePreviewsSettingsPage'; +import { redirect } from 'next/navigation'; +import { SETTINGS_PATHS } from '@/lib/settings'; + +// Live previews are always enabled; the setup experience now lives in the +// task page's preview pane. Redirect old bookmarks to environment settings. export default function Page() { - return ; + redirect(SETTINGS_PATHS.environments); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.client.test.tsx index 88b57fecf..4ea0cce38 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.client.test.tsx @@ -96,7 +96,12 @@ describe('PreviewCommand', () => { }); it('registers the Live Preview command when a preview URL is available', () => { - render(); + render( + , + ); expect(useRegisterCommandsMock).toHaveBeenCalledWith( expect.arrayContaining([ @@ -108,6 +113,17 @@ describe('PreviewCommand', () => { ); }); + it('does not register the command for repo-only tasks', () => { + render( + , + ); + + expect(useRegisterCommandsMock).toHaveBeenCalledWith([]); + }); + it('reuses the remembered preview target when running the command', () => { useTaskSidePanelMock.mockReturnValue({ openPreviewView: openPreviewViewMock, @@ -115,7 +131,12 @@ describe('PreviewCommand', () => { previewServiceName: 'API', }); - render(); + render( + , + ); const livePreviewCommand = useRegisterCommandsMock.mock.calls[0]?.[0]?.find( (command: { id: string }) => command.id === 'task-live-preview', diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.tsx index c418268a3..86b0816e8 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PreviewCommand.tsx @@ -18,8 +18,12 @@ interface PreviewCommandProps { export function PreviewCommand({ taskRun, asleep }: PreviewCommandProps) { const { initialPaths, previewUrl, previewUrls, primaryPortName } = usePreviewUrls(taskRun ?? {}); - const { openPreviewView, previewPath, previewServiceName } = - useTaskSidePanel(); + const { + openPreviewSetupView, + openPreviewView, + previewPath, + previewServiceName, + } = useTaskSidePanel(); const { openPreviewPane } = usePreviewPane(); const { previewServiceName: resolvedPreviewServiceName, @@ -40,13 +44,18 @@ export function PreviewCommand({ taskRun, asleep }: PreviewCommandProps) { const commands: Parameters[0] = []; - if (resolvedPreviewUrl && taskRun?.id) { + if (taskRun?.id && taskRun.payload?.environmentId) { commands.push({ id: 'task-live-preview', icon: AppWindow, label: 'Live Preview', group: 'Task actions', action: () => { + if (!resolvedPreviewUrl) { + openPreviewSetupView(); + return; + } + openPreviewPane( resolvedPreviewUrl, taskRun.id, @@ -67,6 +76,7 @@ export function PreviewCommand({ taskRun, asleep }: PreviewCommandProps) { asleep, taskRun, openPreviewPane, + openPreviewSetupView, openPreviewView, resolvedPreviewServiceName, resolvedPreviewUrl, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-close-preview-on-sleep.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-close-preview-on-sleep.client.test.tsx index d6981cd30..bfdc8670e 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-close-preview-on-sleep.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/__tests__/use-close-preview-on-sleep.client.test.tsx @@ -45,6 +45,7 @@ function buildTaskSidePanelContext({ previewServiceName: null, previewPath: null, openPreviewView: vi.fn(), + openPreviewSetupView: vi.fn(), openArtifactsBrowser: vi.fn(), openDiffView: vi.fn(), openArtifactDetail: vi.fn(), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-side-panel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-side-panel.tsx index b8030de0a..107cdf64b 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-side-panel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-side-panel.tsx @@ -36,6 +36,8 @@ interface TaskSidePanelContextType { /** The iframe path currently shown in the preview panel (from URL or last opened). */ previewPath: string | null; openPreviewView: (url: string, runId: number, serviceName?: string) => void; + /** Open the preview pane without a running preview (shows the setup state). */ + openPreviewSetupView: () => void; openArtifactsBrowser: () => void; openDiffView: () => void; openArtifactDetail: (path: string, version?: number) => void; @@ -65,6 +67,7 @@ const defaultValue: TaskSidePanelContextType = { previewServiceName: null, previewPath: null, openPreviewView: noop, + openPreviewSetupView: noop, openArtifactsBrowser: noop, openDiffView: noop, openArtifactDetail: noop, @@ -106,7 +109,6 @@ function computeBasePath(pathname: string): string { function parseViewFromPathname( pathname: string, search: URLSearchParams, - allowPreview: boolean, ): { view: TaskSidePanelView | null; artifactsMode: ArtifactsMode; @@ -135,7 +137,7 @@ function parseViewFromPathname( // /task/[id]/previews/[service]?path=[path] const previewMatch = pathname.match(/\/previews\/([^/]+)/); - if (previewMatch && allowPreview) { + if (previewMatch) { const serviceName = decodeURIComponent(previewMatch[1]!); const iframePath = search.get('path') ?? null; return { @@ -148,6 +150,18 @@ function parseViewFromPathname( }; } + // /task/[id]/previews — preview pane without a running preview (setup state) + if (/\/previews\/?$/.test(pathname)) { + return { + view: 'preview', + artifactsMode: 'browser', + artifactPath: null, + artifactVersion: undefined, + previewServiceName: null, + previewPath: null, + }; + } + // /task/[id]/info if (/\/info\/?$/.test(pathname)) { return { @@ -303,6 +317,18 @@ export function TaskSidePanelProvider({ [buildPreviewPath, previewPath, previewServiceName], ); + // ------------------------------------------------------------------- + // Open preview pane without a running preview (setup state) + // ------------------------------------------------------------------- + const openPreviewSetupView = useCallback(() => { + setActiveView('preview'); + window.history.replaceState( + window.history.state, + '', + `${basePath}/previews`, + ); + }, [basePath]); + // ------------------------------------------------------------------- // Open artifacts browser (list view) // ------------------------------------------------------------------- @@ -461,7 +487,7 @@ export function TaskSidePanelProvider({ // Sync state from URL on initial load / URL changes // ------------------------------------------------------------------- useEffect(() => { - const parsed = parseViewFromPathname(pathname, searchParams, true); + const parsed = parseViewFromPathname(pathname, searchParams); if (parsed.view === null) { // URL has no side-panel segment; keep UI in sync by ensuring panel is closed. @@ -511,6 +537,7 @@ export function TaskSidePanelProvider({ previewServiceName, previewPath, openPreviewView, + openPreviewSetupView, openArtifactsBrowser, openArtifactDetail, openTaskInfoView, @@ -538,6 +565,7 @@ export function TaskSidePanelProvider({ openArtifactsBrowser, openLogsView, openPreviewView, + openPreviewSetupView, openTerminalView, openTaskInfoView, openDiffView, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.client.test.tsx index 8a346696b..8e4eadf2d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.client.test.tsx @@ -6,6 +6,7 @@ const { useTaskSidePanelMock, usePreviewPaneMock, openPreviewViewMock, + openPreviewSetupViewMock, openPreviewPaneMock, resolvePreviewTargetMock, restoreSnapshotMutateAsyncMock, @@ -14,6 +15,7 @@ const { useTaskSidePanelMock: vi.fn(), usePreviewPaneMock: vi.fn(), openPreviewViewMock: vi.fn(), + openPreviewSetupViewMock: vi.fn(), openPreviewPaneMock: vi.fn(), restoreSnapshotMutateAsyncMock: vi.fn(), resolvePreviewTargetMock: vi.fn( @@ -175,6 +177,7 @@ describe('LivePreviewButton', () => { }); useTaskSidePanelMock.mockReturnValue({ openPreviewView: openPreviewViewMock, + openPreviewSetupView: openPreviewSetupViewMock, previewPath: null, previewServiceName: null, isViewActive: vi.fn(() => false), @@ -188,7 +191,13 @@ describe('LivePreviewButton', () => { render( , ); @@ -217,6 +226,7 @@ describe('LivePreviewButton', () => { it('reopens the last selected preview service and path after switching away', () => { useTaskSidePanelMock.mockReturnValue({ openPreviewView: openPreviewViewMock, + openPreviewSetupView: openPreviewSetupViewMock, previewPath: '/docs?tab=api', previewServiceName: 'API', isViewActive: vi.fn(() => false), @@ -225,7 +235,13 @@ describe('LivePreviewButton', () => { render( , ); @@ -254,7 +270,13 @@ describe('LivePreviewButton', () => { render( , ); @@ -267,11 +289,61 @@ describe('LivePreviewButton', () => { expect(openPreviewViewMock).not.toHaveBeenCalled(); }); + it('renders nothing for repo-only tasks', () => { + render( + , + ); + + expect( + screen.queryByRole('link', { name: 'Live Preview' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Live Preview' }), + ).not.toBeInTheDocument(); + }); + + it('opens the setup view when no preview URL is available', () => { + usePreviewUrlsMock.mockReturnValue({ + initialPaths: {}, + previewUrl: null, + previewUrls: null, + primaryPortName: null, + }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Live Preview' })); + + expect(openPreviewSetupViewMock).toHaveBeenCalledTimes(1); + expect(openPreviewPaneMock).not.toHaveBeenCalled(); + expect(openPreviewViewMock).not.toHaveBeenCalled(); + }); + it('asks to wake the task when live preview is opened from a sleeping task', async () => { render( , ); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.tsx index ac74b8d58..c2e052340 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/LivePreviewButton.tsx @@ -34,8 +34,13 @@ function LivePreviewButtonBase({ const [showWakeDialog, setShowWakeDialog] = useState(false); const { initialPaths, previewUrl, previewUrls, primaryPortName } = usePreviewUrls(taskRun ?? {}); - const { isViewActive, openPreviewView, previewPath, previewServiceName } = - useTaskSidePanel(); + const { + isViewActive, + openPreviewSetupView, + openPreviewView, + previewPath, + previewServiceName, + } = useTaskSidePanel(); const { openPreviewPane } = usePreviewPane(); const restoreSnapshot = useRestoreTaskRunSnapshot({ onSuccess: () => setShowWakeDialog(false), @@ -52,6 +57,12 @@ function LivePreviewButtonBase({ primaryPortName, }); + // Repo-only tasks have no environment to preview, so the button is hidden + // entirely rather than opening a pane with nothing actionable in it. + if (!taskRun?.payload?.environmentId) { + return null; + } + const asleep = isTaskRunAsleep(taskRun); const canWakeForPreview = asleep && !!taskRun?.snapshotId && !!resolvedPreviewUrl; @@ -59,20 +70,15 @@ function LivePreviewButtonBase({ resolvedPreviewUrl && taskRun ? buildPreviewIframeUrl(resolvedPreviewUrl, taskRun.id) : null; - const disabled = - disabledUntilReady || - !taskRun || - !resolvedPreviewUrl || - (asleep && !canWakeForPreview); + const hasPreviewUrl = Boolean(resolvedPreviewUrl); + const disabled = disabledUntilReady || !taskRun; const tooltip = disabledUntilReady ? undefined : canWakeForPreview ? 'Wake up Roomote to use Live Preview' - : asleep - ? 'Live Preview is only available when Roomote is awake' - : !resolvedPreviewUrl - ? 'Live Preview is not available' - : 'Live Preview'; + : !hasPreviewUrl + ? 'Set up Live Preview' + : 'Live Preview'; const handleWakeConfirm = async () => { if (!taskRun?.snapshotId || restoreSnapshot.isPending) { @@ -123,18 +129,28 @@ function LivePreviewButtonBase({ ? 'Wake this task so live preview becomes available' : disabled ? undefined - : "Preview this task's app" + : hasPreviewUrl + ? "Preview this task's app" + : 'Set up live previews for this task' } active={!disabled && !canWakeForPreview && isViewActive('preview')} disabled={disabled} href={ - canWakeForPreview || disabled ? undefined : (openUrl ?? undefined) + canWakeForPreview || disabled || !hasPreviewUrl + ? undefined + : (openUrl ?? undefined) } useNativeLink={true} - onClick={canWakeForPreview ? () => setShowWakeDialog(true) : undefined} + onClick={ + canWakeForPreview + ? () => setShowWakeDialog(true) + : !hasPreviewUrl + ? openPreviewSetupView + : undefined + } icon={AppWindow} linkProps={ - disabled || canWakeForPreview + disabled || canWakeForPreview || !hasPreviewUrl ? undefined : { rel: 'noreferrer', diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewHelpDialog.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewHelpDialog.client.test.tsx new file mode 100644 index 000000000..dd3e8e159 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewHelpDialog.client.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { PreviewHelpDialog } from './PreviewHelpDialog'; + +const { authState, statusState, startSetupMock } = vi.hoisted(() => ({ + authState: { isAdmin: false }, + statusState: { + data: { + runtimeReady: true, + environment: { + id: 'env-1', + name: 'Web App', + hasConfiguredPorts: true, + portNames: ['WEB'], + }, + runHasPreviewDomains: true, + setupTask: null as { + taskId: string | null; + status: string; + kind: 'preview' | 'environment'; + } | null, + }, + }, + startSetupMock: vi.fn( + async (_variables: { taskId: string; mode?: string }) => ({ + taskId: 'fix-task-1', + alreadyRunning: false, + }), + ), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ + isAdmin: authState.isAdmin, + }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + previewSettings: { + taskStatus: { + queryOptions: ( + input: { taskId: string }, + options?: Record, + ) => ({ + queryKey: ['previewTaskStatus', input.taskId], + queryFn: () => statusState.data, + ...options, + }), + queryKey: (input: { taskId: string }) => [ + 'previewTaskStatus', + input.taskId, + ], + }, + startSetupTask: { + mutationOptions: (options?: Record) => ({ + mutationFn: startSetupMock, + ...options, + }), + }, + }, + }), +})); + +function renderDialog(taskId: string | null = 'task-1') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +} + +describe('PreviewHelpDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + authState.isAdmin = false; + statusState.data = { + runtimeReady: true, + environment: { + id: 'env-1', + name: 'Web App', + hasConfiguredPorts: true, + portNames: ['WEB'], + }, + runHasPreviewDomains: true, + setupTask: null, + }; + }); + + it('lets admins launch a repair agent', async () => { + authState.isAdmin = true; + + renderDialog(); + + const button = await screen.findByRole('button', { + name: /fix previews with an agent/i, + }); + fireEvent.click(button); + + await waitFor(() => { + expect(startSetupMock).toHaveBeenCalledTimes(1); + }); + expect(startSetupMock.mock.calls[0]?.[0]).toEqual({ + taskId: 'task-1', + mode: 'repair', + }); + }); + + it('tells non-admins to ask an administrator', async () => { + renderDialog(); + + expect( + await screen.findByText( + 'Ask an administrator to fix live previews for this environment.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /fix previews with an agent/i }), + ).not.toBeInTheDocument(); + }); + + it('links to an in-flight agent task instead of offering a new launch', async () => { + authState.isAdmin = true; + statusState.data = { + ...statusState.data, + setupTask: { taskId: 'fix-task-9', status: 'running', kind: 'preview' }, + }; + + renderDialog(); + + expect( + await screen.findByRole('link', { name: /view agent task/i }), + ).toHaveAttribute('href', '/task/fix-task-9'); + expect( + screen.queryByRole('button', { name: /fix previews with an agent/i }), + ).not.toBeInTheDocument(); + }); + + it('shows the in-flight message without a task link when the id is withheld', async () => { + statusState.data = { + ...statusState.data, + setupTask: { taskId: null, status: 'running', kind: 'preview' }, + }; + + renderDialog(); + + expect( + await screen.findByText( + 'An agent is already working on live previews for Web App.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /view agent task/i }), + ).not.toBeInTheDocument(); + }); + + it('explains ongoing environment setup instead of claiming a preview agent', async () => { + authState.isAdmin = true; + statusState.data = { + ...statusState.data, + setupTask: { + taskId: 'env-setup-9', + status: 'running', + kind: 'environment', + }, + }; + + renderDialog(); + + expect( + await screen.findByText( + 'Web App is still being set up. Previews may not work until setup completes.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /fix previews with an agent/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewHelpDialog.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewHelpDialog.tsx new file mode 100644 index 000000000..068975958 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewHelpDialog.tsx @@ -0,0 +1,129 @@ +'use client'; + +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useAuthorizedUser } from '@/hooks/useUser'; +import { useTRPC } from '@/trpc/client'; +import { + ArrowRight, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Sparkles, + Spinner, +} from '@/components/system'; + +/** + * Help flow for a preview that exists but does not load or work correctly + * (host checks, CORS, frame-blocking headers, and similar). Admins can launch + * a repair agent for the task's environment; members are pointed to an admin. + */ +export function PreviewHelpDialog({ + taskId, + open, + onOpenChange, +}: { + taskId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { isAdmin } = useAuthorizedUser(); + const statusQuery = useQuery( + trpc.previewSettings.taskStatus.queryOptions( + { taskId: taskId ?? '' }, + { enabled: open && Boolean(taskId) }, + ), + ); + const startRepairMutation = useMutation( + trpc.previewSettings.startSetupTask.mutationOptions({ + onSuccess: (result) => { + if (result.alreadyRunning) { + toast.info('An agent is already working on this environment'); + } else { + toast.success('Preview fix agent started'); + } + + queryClient.invalidateQueries({ + queryKey: trpc.previewSettings.taskStatus.queryKey({ + taskId: taskId ?? '', + }), + }); + }, + onError: () => { + toast.error('Failed to start the preview fix agent'); + }, + }), + ); + + const setupTask = statusQuery.data?.setupTask ?? null; + const environmentName = statusQuery.data?.environment?.name; + + return ( + + + + Preview not working? + + Fresh tasks can take a couple of minutes to start their app. If the + preview stays blank or broken, the app may need changes to run + behind the preview proxy, like allowing the preview host, framing, + or cross-origin requests. + + + + {setupTask ? ( +
+

+ {setupTask.kind === 'preview' + ? `An agent is already working on live previews${environmentName ? ` for ${environmentName}` : ''}.` + : `${environmentName ?? 'This environment'} is still being set up. Previews may not work until setup completes.`} +

+ {setupTask.taskId ? ( + + ) : null} +
+ ) : isAdmin ? ( +

+ An agent can reproduce the problem in this environment and fix its + configuration. If the app itself needs changes, it reports exactly + what is needed. +

+ ) : ( +

+ Ask an administrator to fix live previews for this environment. +

+ )} + + + + {isAdmin && !setupTask ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSetupState.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSetupState.client.test.tsx new file mode 100644 index 000000000..0e0100cca --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSetupState.client.test.tsx @@ -0,0 +1,292 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import type { TaskRun } from '@roomote/db'; + +import { PreviewSetupState } from './PreviewSetupState'; + +const { authState, statusState, startSetupMock } = vi.hoisted(() => ({ + authState: { isAdmin: false }, + statusState: { + data: null as { + runtimeReady: boolean; + environment: { + id: string; + name: string; + hasConfiguredPorts: boolean; + portNames: string[]; + } | null; + runHasPreviewDomains: boolean; + setupTask: { + taskId: string | null; + status: string; + kind: 'preview' | 'environment'; + } | null; + } | null, + }, + startSetupMock: vi.fn(async (_variables: { taskId: string }) => ({ + taskId: 'setup-task-1', + alreadyRunning: false, + })), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ + isAdmin: authState.isAdmin, + }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + previewSettings: { + taskStatus: { + queryOptions: ( + input: { taskId: string }, + options?: Record, + ) => ({ + queryKey: ['previewTaskStatus', input.taskId], + queryFn: () => statusState.data, + ...options, + refetchInterval: false, + }), + queryKey: (input: { taskId: string }) => [ + 'previewTaskStatus', + input.taskId, + ], + }, + startSetupTask: { + mutationOptions: (options?: Record) => ({ + mutationFn: startSetupMock, + ...options, + }), + }, + }, + }), +})); + +vi.mock('@/components/previews/PreviewRuntimeSetup', () => ({ + PreviewRuntimeSetup: () =>
runtime-setup-blocks
, +})); + +const taskRun = { id: 1, taskId: 'task-1' } as TaskRun; + +function renderSetupState(run?: TaskRun) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +} + +function buildStatus( + overrides: Partial> = {}, +) { + return { + runtimeReady: true, + environment: { + id: 'env-1', + name: 'Web App', + hasConfiguredPorts: false, + portNames: [], + }, + runHasPreviewDomains: false, + setupTask: null, + ...overrides, + }; +} + +describe('PreviewSetupState', () => { + beforeEach(() => { + vi.clearAllMocks(); + authState.isAdmin = false; + statusState.data = buildStatus(); + }); + + it('shows the generic unavailable message without a task run', () => { + renderSetupState(undefined); + + expect( + screen.getByText('Live Preview is not available for this task.'), + ).toBeInTheDocument(); + }); + + it('explains repo-only tasks have no environment to preview', async () => { + statusState.data = buildStatus({ environment: null }); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'Live previews are available for tasks that run in an environment', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Create an environment')).not.toBeInTheDocument(); + }); + + it('shows no environment-creation CTA for repo-only tasks, even for admins', async () => { + authState.isAdmin = true; + statusState.data = buildStatus({ environment: null }); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'Live previews are available for tasks that run in an environment', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Create an environment')).not.toBeInTheDocument(); + }); + + it('links to an in-flight setup task', async () => { + statusState.data = buildStatus({ + setupTask: { taskId: 'setup-task-9', status: 'running', kind: 'preview' }, + }); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'An agent is setting up live previews for Web App', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view setup task/i }), + ).toHaveAttribute('href', '/task/setup-task-9'); + }); + + it('shows setup progress without a task link when the id is withheld', async () => { + statusState.data = buildStatus({ + setupTask: { taskId: null, status: 'running', kind: 'preview' }, + }); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'An agent is setting up live previews for Web App', + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /view setup task/i }), + ).not.toBeInTheDocument(); + }); + + it('explains ongoing environment setup without claiming a preview agent', async () => { + statusState.data = buildStatus({ + setupTask: { + taskId: 'env-setup-1', + status: 'running', + kind: 'environment', + }, + }); + + renderSetupState(taskRun); + + expect( + await screen.findByText('Web App is still being set up'), + ).toBeInTheDocument(); + expect( + screen.queryByText(/setting up live previews/), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view setup task/i }), + ).toHaveAttribute('href', '/task/env-setup-1'); + }); + + it('tells non-admins to ask an administrator when the runtime is not ready', async () => { + statusState.data = buildStatus({ runtimeReady: false }); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'Ask an administrator to configure live previews.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('runtime-setup-blocks')).not.toBeInTheDocument(); + }); + + it('shows admins the runtime setup blocks when the runtime is not ready', async () => { + authState.isAdmin = true; + statusState.data = buildStatus({ runtimeReady: false }); + + renderSetupState(taskRun); + + expect(await screen.findByText('runtime-setup-blocks')).toBeInTheDocument(); + }); + + it('lets admins launch the setup agent for environments without ports', async () => { + authState.isAdmin = true; + statusState.data = buildStatus(); + + renderSetupState(taskRun); + + const button = await screen.findByRole('button', { + name: /set up previews with an agent/i, + }); + fireEvent.click(button); + + await waitFor(() => { + expect(startSetupMock).toHaveBeenCalledTimes(1); + }); + expect(startSetupMock.mock.calls[0]?.[0]).toEqual({ taskId: 'task-1' }); + }); + + it('tells non-admins to ask an administrator for environments without ports', async () => { + statusState.data = buildStatus(); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'Ask an administrator to set up live previews for this environment.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /set up previews with an agent/i }), + ).not.toBeInTheDocument(); + }); + + it('offers admins a manual ports link for environments without ports', async () => { + authState.isAdmin = true; + statusState.data = buildStatus(); + + renderSetupState(taskRun); + + expect( + await screen.findByRole('link', { name: /configure ports manually/i }), + ).toHaveAttribute('href', '/settings/environments/env-1/edit'); + }); + + it('explains sleep/wake for runs that predate configured ports', async () => { + statusState.data = buildStatus({ + environment: { + id: 'env-1', + name: 'Web App', + hasConfiguredPorts: true, + portNames: ['WEB', 'API'], + }, + }); + + renderSetupState(taskRun); + + expect( + await screen.findByText( + 'This task started before live previews were configured for Web App', + ), + ).toBeInTheDocument(); + expect(screen.getByText('WEB, API')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSetupState.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSetupState.tsx new file mode 100644 index 000000000..f7de87608 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSetupState.tsx @@ -0,0 +1,243 @@ +'use client'; + +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import type { TaskRun } from '@roomote/db'; + +import { SETTINGS_PATHS } from '@/lib/settings'; +import { useAuthorizedUser } from '@/hooks/useUser'; +import { useTRPC } from '@/trpc/client'; +import type { TaskPreviewStatus } from '@/trpc/commands/preview-settings'; +import { + AppWindow, + ArrowRight, + Button, + Skeleton, + Sparkles, + Spinner, +} from '@/components/system'; +import { PreviewRuntimeSetup } from '@/components/previews/PreviewRuntimeSetup'; + +function CenteredMessage({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +/** + * Rendered in the preview pane when the task has no live preview URL. Explains + * why, and offers the path to getting previews working: launching a setup + * agent, configuring ports, or (for admins) configuring the preview runtime. + */ +export function PreviewSetupState({ taskRun }: { taskRun?: TaskRun }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { isAdmin } = useAuthorizedUser(); + const taskId = taskRun?.taskId; + const statusQuery = useQuery( + trpc.previewSettings.taskStatus.queryOptions( + { taskId: taskId ?? '' }, + { + enabled: Boolean(taskId), + refetchInterval: (query) => + query.state.data?.setupTask ? 5_000 : 30_000, + }, + ), + ); + const startSetupMutation = useMutation( + trpc.previewSettings.startSetupTask.mutationOptions({ + onSuccess: (result) => { + if (result.alreadyRunning) { + toast.info('An agent is already working on this environment'); + } else { + toast.success('Preview setup agent started'); + } + + queryClient.invalidateQueries({ + queryKey: trpc.previewSettings.taskStatus.queryKey({ + taskId: taskId ?? '', + }), + }); + }, + onError: () => { + toast.error('Failed to start the preview setup agent'); + }, + }), + ); + + if (!taskId || statusQuery.isError) { + return ( + +

+ Live Preview is not available for this task. +

+
+ ); + } + + if (statusQuery.isPending) { + return ( +
+
+ + + +
+
+ ); + } + + const status: TaskPreviewStatus = statusQuery.data; + + // Repo-only tasks have no environment to preview. The Live Preview button + // is hidden for these tasks, so this state is only reachable via a direct + // /previews URL. + if (!status.environment) { + return ( + + +

+ Live previews are available for tasks that run in an environment +

+

+ This task runs directly against the repository, so there is no running + app to preview. +

+
+ ); + } + + // An agent is already working on this environment: either a preview + // setup/repair agent or the environment's own setup/verification task. + if (status.setupTask) { + const isPreviewAgent = status.setupTask.kind === 'preview'; + + return ( + + +

+ {isPreviewAgent + ? `An agent is setting up live previews for ${status.environment.name}` + : `${status.environment.name} is still being set up`} +

+

+ {isPreviewAgent + ? 'Once it finishes, new tasks in this environment will include a live preview.' + : 'Live previews become available once the environment is ready.'} +

+ {status.setupTask.taskId ? ( + + ) : null} +
+ ); + } + + // Preview infrastructure is not configured for this deployment. + if (!status.runtimeReady) { + if (!isAdmin) { + return ( + + +

+ Live previews aren't set up for this deployment yet +

+

+ Ask an administrator to configure live previews. +

+
+ ); + } + + return ( +
+
+

+ Live previews need a one-time deployment setup before environments + can publish preview URLs. +

+ +
+
+ ); + } + + // Environment has no preview ports configured yet. + if (!status.environment.hasConfiguredPorts) { + return ( + + +

+ {status.environment.name} doesn't expose any preview ports yet +

+ {isAdmin ? ( + <> +

+ An agent can find the web app in this environment, verify that it + runs, and publish a live preview for future tasks. +

+ +

+ Or{' '} + + configure ports manually + + . +

+ + ) : ( +

+ Ask an administrator to set up live previews for this environment. +

+ )} +
+ ); + } + + // Ports are configured, but this run started before they existed. + return ( + + +

+ This task started before live previews were configured for{' '} + {status.environment.name} +

+

+ New tasks will include a live preview of{' '} + + {status.environment.portNames.join(', ')} + + . To attach a preview to this task, put it to sleep and wake it again. +

+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.client.test.tsx index 8a3b80040..d957ebbf1 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.client.test.tsx @@ -77,8 +77,10 @@ vi.mock('@/components/system', () => ({ ) { return ; }), + LifeBuoyIcon: () => , Lock: () => , Loader2: (props: Record) => loading, + X: () => , RectangleHorizontal: () => , ArrowLeft: () => , ArrowRight: () => , @@ -107,6 +109,10 @@ vi.mock('../hooks/use-task-side-panel', () => ({ useTaskSidePanel: useTaskSidePanelMock, })); +vi.mock('./PreviewHelpDialog', () => ({ + PreviewHelpDialog: () => null, +})); + vi.mock('./SidePanelHeader', () => ({ SidePanelHeader: ({ title, @@ -549,4 +555,81 @@ describe('PreviewSidePanel', () => { screen.queryByRole('button', { name: 'Show preview widget' }), ).not.toBeInTheDocument(); }); + + it('shows a services-starting notice while environment setup is running', () => { + render( + , + ); + + expect( + screen.getByText(/Environment services are still starting/), + ).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss preview starting notice' }), + ); + + expect( + screen.queryByText(/Environment services are still starting/), + ).not.toBeInTheDocument(); + }); + + it('hides the services-starting notice once the preview reports loading', () => { + render( + , + ); + + expect( + screen.getByText(/Environment services are still starting/), + ).toBeInTheDocument(); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'roomote-load-complete' }, + }), + ); + }); + + expect( + screen.queryByText(/Environment services are still starting/), + ).not.toBeInTheDocument(); + }); + + it('does not show the services-starting notice when setup has completed', () => { + render( + , + ); + + expect( + screen.queryByText(/Environment services are still starting/), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.tsx index 01850394a..d58e958fb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/PreviewSidePanel.tsx @@ -23,6 +23,7 @@ import { DropdownMenuTrigger, ExternalLink, Input, + LifeBuoyIcon, Lock, Loader2, RectangleHorizontal, @@ -32,6 +33,7 @@ import { TooltipProvider, TooltipTrigger, DropdownMenuLabel, + X, } from '@/components/system'; import { buildPreviewIframeUrl } from '../preview-iframe-url'; @@ -41,6 +43,8 @@ import { usePreviewUrls } from '../hooks/use-preview-urls'; import { useTaskSidePanel } from '../hooks/use-task-side-panel'; import { shouldIncludeInPreviewServiceList } from '../preview-port-utils'; +import { PreviewHelpDialog } from './PreviewHelpDialog'; +import { PreviewSetupState } from './PreviewSetupState'; import { SidePanelHeader } from './SidePanelHeader'; interface PreviewEntry { @@ -165,6 +169,22 @@ export function PreviewSidePanel({ height: MOBILE_HEIGHT, }); const [isResizing, setIsResizing] = useState(false); + const [isHelpOpen, setIsHelpOpen] = useState(false); + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + const [loadTimedOut, setLoadTimedOut] = useState(false); + const [isLoadWarningDismissed, setIsLoadWarningDismissed] = useState(false); + const [isSetupNoticeDismissed, setIsSetupNoticeDismissed] = useState(false); + const wasSetupRunningRef = useRef(false); + + // Environment setup progress, mirrored from the workspace's + // .roomote/setup-status.json onto the run. While setup commands and + // services are still starting, a failing preview is expected rather than a + // problem worth warning about. + const environmentSetupState = taskRun?.environmentSetupState ?? null; + const environmentSetupRunning = environmentSetupState === 'running'; + const environmentSetupTroubled = + environmentSetupState === 'failed' || + environmentSetupState === 'completed_with_warnings'; const resolvedPrimaryPortName = useMemo( () => @@ -323,6 +343,8 @@ export function PreviewSidePanel({ if (event.data?.type === 'roomote-load-complete') { setIsNavigating(false); setIsWidgetHidden(false); + setLoadTimedOut(false); + setHasLoadedOnce(true); hasInitiallyLoadedRef.current = true; if (navigatingTimeoutRef.current) { @@ -392,6 +414,10 @@ export function PreviewSidePanel({ setCurrentUrl(null); setIsNavigating(false); setIsWidgetHidden(false); + setHasLoadedOnce(false); + setLoadTimedOut(false); + setIsLoadWarningDismissed(false); + setIsSetupNoticeDismissed(false); hasInitiallyLoadedRef.current = false; pendingIframeNavigationUrlRef.current = null; retryCountRef.current = 0; @@ -403,6 +429,34 @@ export function PreviewSidePanel({ } }, [effectiveRunId, effectivePreviewUrl]); + // When environment setup finishes without the preview ever loading, give + // the freshly started services a fair chance: reset the retry window and + // reload the iframe. + useEffect(() => { + const wasRunning = wasSetupRunningRef.current; + wasSetupRunningRef.current = environmentSetupRunning; + + // hasLoadedOnce (not hasInitiallyLoadedRef) is the guard here: the ref is + // also set when the retry window expires, and a timed-out preview is + // exactly the case that deserves a fresh chance after setup finishes. + if (!wasRunning || environmentSetupRunning || hasLoadedOnce) { + return; + } + + retryStartRef.current = null; + retryCountRef.current = 0; + hasInitiallyLoadedRef.current = false; + setLoadTimedOut(false); + + const iframe = iframeRef.current; + + if (iframe?.src) { + const src = iframe.src; + iframe.src = ''; + iframe.src = src; + } + }, [environmentSetupRunning, hasLoadedOnce]); + const handleClose = () => { closePreviewPane(); onClose(); @@ -572,7 +626,11 @@ export function PreviewSidePanel({ } }, delay); } else { + // The retry window is exhausted without the injected widget reporting a + // successful load. The app may still be visible (some apps block the + // widget), so surface a dismissible warning rather than an error state. hasInitiallyLoadedRef.current = true; + setLoadTimedOut(true); } }, []); @@ -614,6 +672,22 @@ export function PreviewSidePanel({ const headerActions = activeEntry ? ( <> + + + + + + Preview not working? + + + + ) : loadTimedOut && !isLoadWarningDismissed ? ( +
+ + {environmentSetupTroubled + ? 'Environment setup reported problems, which may be why the preview is not loading.' + : "The preview hasn't reported loading. It may still be starting, or something may be blocking it."} + +
+ + +
+
+ ) : null} ) : ( -
- Live Preview is not available for this task. -
+ )} + + ); } diff --git a/apps/web/src/components/previews/PreviewRuntimeSetup.tsx b/apps/web/src/components/previews/PreviewRuntimeSetup.tsx new file mode 100644 index 000000000..bd1fa43e8 --- /dev/null +++ b/apps/web/src/components/previews/PreviewRuntimeSetup.tsx @@ -0,0 +1,372 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { isLocalPreviewDomain } from '@roomote/types'; + +import { useTRPC } from '@/trpc/client'; +import type { PreviewSettingsSnapshot } from '@/trpc/commands/preview-settings'; + +import { + Alert, + AlertDescription, + Button, + Check, + CopyIconButton, + ExternalLink, + Info, + Input, + Skeleton, + Spinner, + TriangleAlert, +} from '@/components/system'; +import { TaskStatusIndicator } from '@/components/sandbox'; + +function getPreviewSetupHost( + runtime: PreviewSettingsSnapshot['effectiveConfig'], +) { + return ( + runtime.roomotePreviewDomain ?? + runtime.previewProxyHostname ?? + runtime.primaryPreviewDomain + ); +} + +function getPreviewExampleDisplay( + runtime: PreviewSettingsSnapshot['effectiveConfig'], +) { + if (!runtime.exampleHostname) { + return 'Unavailable'; + } + + if (!isLocalPreviewDomain(runtime.primaryPreviewDomain)) { + return runtime.exampleHostname; + } + + if (!runtime.previewProxyBaseUrl) { + return runtime.exampleHostname; + } + + try { + const baseUrl = new URL(runtime.previewProxyBaseUrl); + baseUrl.hostname = runtime.exampleHostname; + return baseUrl.toString().replace(/\/$/, ''); + } catch { + return runtime.exampleHostname; + } +} + +/** + * Admin-only live preview infrastructure setup: runtime status, preview origin + * configuration, DNS records, and a DNS check. Rendered inside the task page's + * preview pane when the preview runtime is not ready. + */ +export function PreviewRuntimeSetup() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const settingsQuery = useQuery(trpc.previewSettings.get.queryOptions()); + const [runtimeDraft, setRuntimeDraft] = useState({ + previewProxyBaseUrl: '', + }); + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}); + const runtimeMutation = useMutation( + trpc.previewSettings.updateRuntimeConfig.mutationOptions({ + onSuccess: (result) => { + if (!result.success) { + setFieldErrors(result.fieldErrors); + toast.error('Fix the preview runtime settings and try again'); + return; + } + + setFieldErrors({}); + queryClient.setQueryData( + trpc.previewSettings.get.queryKey(), + result.snapshot, + ); + toast.success('Live preview runtime settings updated'); + }, + onError: () => { + toast.error('Failed to update live preview runtime settings'); + }, + }), + ); + const effectivePreviewOriginValue = + settingsQuery.data?.effectiveConfig.previewProxyBaseUrl ?? null; + + useEffect(() => { + if (effectivePreviewOriginValue === null) { + return; + } + + setRuntimeDraft({ + previewProxyBaseUrl: effectivePreviewOriginValue, + }); + setFieldErrors({}); + }, [effectivePreviewOriginValue]); + + if (settingsQuery.isPending) { + return ( +
+ + + +
+ ); + } + + if (!settingsQuery.data) { + return ( + + + Live preview settings are unavailable right now. + + + ); + } + + const settings = settingsQuery.data; + const effectivePreviewOrigin = settings.effectiveConfig.previewProxyBaseUrl; + const displayedPreviewOrigin = effectivePreviewOrigin || ''; + const previewOriginManagedByEnv = + settings.configSource.previewOriginManagedByEnv; + const runtimeValidation = settings.effectiveConfig.validation; + const previewSetupHost = getPreviewSetupHost(settings.effectiveConfig); + const isLocalSetup = isLocalPreviewDomain(previewSetupHost); + const isSetupReady = runtimeValidation.status === 'pass'; + const setupStatusText = isSetupReady + ? 'Preview setup is ready' + : 'Preview setup needs attention'; + const isRuntimeDirty = + runtimeDraft.previewProxyBaseUrl !== displayedPreviewOrigin; + const previewExampleDisplay = getPreviewExampleDisplay( + settings.effectiveConfig, + ); + const previewExampleHref = settings.effectiveConfig.exampleHostname + ? previewExampleDisplay.startsWith('http') + ? previewExampleDisplay + : `https://${previewExampleDisplay}` + : null; + + return ( +
+
+

Status

+
+ +
+

{setupStatusText}

+

+ {runtimeValidation.summary} +

+
+
+
+ + {runtimeValidation.status === 'fail' && + runtimeValidation.details.length > 0 ? ( +

+ + {runtimeValidation.details.join(' ')} +

+ ) : null} + + {settings.overrideState.hasOverrides ? ( + + + Runtime environment variables are currently overriding the saved + deployment preview settings. + + + ) : null} + +
+

Configuration

+ +
+ + 1 + +
+ +
+ { + setRuntimeDraft((current) => ({ + ...current, + previewProxyBaseUrl: event.target.value, + })); + if (fieldErrors.previewProxyBaseUrl) { + setFieldErrors((current) => ({ + ...current, + previewProxyBaseUrl: undefined, + })); + } + }} + aria-label="Preview origin" + placeholder="eg: https://preview.example.com or http://previews.acme.ai:4000" + /> + + {!previewOriginManagedByEnv && ( + + )} +
+ + {fieldErrors.previewProxyBaseUrl ? ( +

+ {fieldErrors.previewProxyBaseUrl} +

+ ) : null} + + {previewOriginManagedByEnv && ( +

+ + Currently defined by PREVIEW_PROXY_BASE_URL, can't be + changed here. +

+ )} +
+
+ + {isLocalSetup ? null : ( +
+ + 2 + +
+

+ Create both of the following DNS records +

+ +
+
+
+ Base host + CNAME + + {previewSetupHost ?? 'preview.example.com'} + + +
+
+ Wildcard host + CNAME + + *.{previewSetupHost ?? 'preview.example.com'} + + +
+
+
    +
  • + Make sure your SSL certificate covers both the base host and + the wildcard host +
  • +
  • + If you need to point to IP addresses, use A/AAAA records + instead of CNAME +
  • +
+
+
+
+ )} + +
+
+ + {isLocalSetup ? '2' : '3'} + +
+

Check your configuration

+
+ {previewExampleDisplay} +
+ +
+ {!isLocalSetup && ( + + )} + + {previewExampleHref ? ( + + ) : ( + + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsShell.client.test.tsx b/apps/web/src/components/settings/SettingsShell.client.test.tsx index df16fb7ed..acb4a0ca6 100644 --- a/apps/web/src/components/settings/SettingsShell.client.test.tsx +++ b/apps/web/src/components/settings/SettingsShell.client.test.tsx @@ -92,9 +92,6 @@ describe('SettingsShell', () => { expect( screen.getByRole('link', { name: /environments/i }), ).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: /live previews/i }), - ).toBeInTheDocument(); expect( screen.getByRole('link', { name: /agent guidance/i }), ).toBeInTheDocument(); @@ -147,26 +144,16 @@ describe('SettingsShell', () => { ); }); - it('places live previews after environments in the admin settings rail', () => { + it('does not offer a live previews page in the admin settings rail', () => { render( - +
content
, ); - const environmentsLink = screen.getByRole('link', { - name: /environments/i, - }); - const previewsLink = screen.getByRole('link', { - name: /live previews/i, - }); - - expect(environmentsLink.compareDocumentPosition(previewsLink)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); expect( - screen.getByText('Enable live previews of the results of Roomote tasks.'), - ).toBeInTheDocument(); + screen.queryByRole('link', { name: /live previews/i }), + ).not.toBeInTheDocument(); }); it('places users after skills in the admin settings rail', () => { diff --git a/apps/web/src/components/settings/pages/LivePreviewsSettingsPage.tsx b/apps/web/src/components/settings/pages/LivePreviewsSettingsPage.tsx deleted file mode 100644 index faed761c4..000000000 --- a/apps/web/src/components/settings/pages/LivePreviewsSettingsPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client'; - -import { SettingsShell } from '@/components/settings/SettingsShell'; -import { LivePreviewsSettings } from '@/components/settings/previews/LivePreviewsSettings'; - -export function LivePreviewsSettingsPage() { - return ( - - - - ); -} diff --git a/apps/web/src/components/settings/previews/LivePreviewsSettings.client.test.tsx b/apps/web/src/components/settings/previews/LivePreviewsSettings.client.test.tsx deleted file mode 100644 index f350a0092..000000000 --- a/apps/web/src/components/settings/previews/LivePreviewsSettings.client.test.tsx +++ /dev/null @@ -1,715 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import type { ReactNode } from 'react'; -import type { PreviewSettingsSnapshot } from '@/trpc/commands/preview-settings'; - -const state = vi.hoisted(() => ({ - data: null as PreviewSettingsSnapshot | null, - refetch: vi.fn(), - deploymentMutate: vi.fn(), - runtimeMutate: vi.fn(), - environmentMutate: vi.fn(), - setQueryData: vi.fn(), - invalidateQueries: vi.fn().mockResolvedValue(undefined), -})); - -function getStateData(): PreviewSettingsSnapshot { - if (!state.data) { - throw new Error('Missing preview settings test data'); - } - - return state.data; -} - -function Icon() { - return ; -} - -vi.mock('next/link', () => ({ - default: ({ - children, - href, - ...props - }: { - children: ReactNode; - href: string; - }) => ( - - {children} - - ), -})); - -vi.mock('sonner', () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('@tanstack/react-query', () => ({ - useQuery: () => ({ - data: state.data, - isPending: false, - isFetching: false, - refetch: state.refetch, - }), - useMutation: (options: { __kind: string }) => - options.__kind === 'deployment' - ? { - mutate: state.deploymentMutate, - isPending: false, - } - : options.__kind === 'runtime' - ? { - mutate: state.runtimeMutate, - isPending: false, - } - : { - mutate: state.environmentMutate, - isPending: false, - variables: null, - }, - useQueryClient: () => ({ - setQueryData: state.setQueryData, - invalidateQueries: state.invalidateQueries, - }), -})); - -vi.mock('@/trpc/client', () => ({ - useTRPC: () => ({ - previewSettings: { - get: { - queryOptions: () => ({ __kind: 'query' }), - queryKey: () => ['previewSettings'], - }, - setDeploymentEnabled: { - mutationOptions: (options: Record = {}) => ({ - ...options, - __kind: 'deployment', - }), - }, - updateRuntimeConfig: { - mutationOptions: (options: Record = {}) => ({ - ...options, - __kind: 'runtime', - }), - }, - updateEnvironmentPreview: { - mutationOptions: (options: Record = {}) => ({ - ...options, - __kind: 'environment', - }), - }, - }, - environments: { - list: { queryKey: () => ['environments'] }, - byId: { queryKey: ({ id }: { id: string }) => ['environment', id] }, - }, - }), -})); - -vi.mock('@/components/system', () => ({ - Alert: ({ children }: { children: ReactNode }) =>
{children}
, - AlertDescription: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - ArrowRight: Icon, - Button: ({ - children, - asChild, - onClick, - disabled, - ...props - }: { - children: ReactNode; - asChild?: boolean; - onClick?: () => void; - disabled?: boolean; - }) => - asChild ? ( - {children} - ) : ( - - ), - Card: ({ children }: { children: ReactNode }) =>
{children}
, - CardContent: ({ children }: { children: ReactNode }) =>
{children}
, - CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, - CardTitle: ({ children }: { children: ReactNode }) =>
{children}
, - Check: Icon, - CopyIconButton: ({ 'aria-label': ariaLabel }: { 'aria-label'?: string }) => ( - - - - - { - setIsDialogOpen(open); - if (!open) { - setDraftPorts(environment.config.ports); - } - }} - > - - - {environment.name} preview settings - - Choose which ports can be opened in live previews. - - - -
- - - {draftPorts && draftPorts.length > 1 ? ( -
-

- Primary service (mapped to 80 in the preview URL): -

- -
- ) : null} -
- - - - - -
-
- - ); -} - -export function LivePreviewsSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const settingsQuery = useQuery(trpc.previewSettings.get.queryOptions()); - const [runtimeDraft, setRuntimeDraft] = useState({ - previewProxyBaseUrl: '', - }); - const [fieldErrors, setFieldErrors] = useState< - Partial> - >({}); - const deploymentMutation = useMutation( - trpc.previewSettings.setDeploymentEnabled.mutationOptions({ - onSuccess: (result) => { - queryClient.setQueryData(trpc.previewSettings.get.queryKey(), result); - toast.success('Live preview deployment setting updated'); - }, - onError: () => { - toast.error('Failed to update live preview deployment setting'); - }, - }), - ); - const runtimeMutation = useMutation( - trpc.previewSettings.updateRuntimeConfig.mutationOptions({ - onSuccess: (result) => { - if (!result.success) { - setFieldErrors(result.fieldErrors); - toast.error('Fix the preview runtime settings and try again'); - return; - } - - setFieldErrors({}); - queryClient.setQueryData( - trpc.previewSettings.get.queryKey(), - result.snapshot, - ); - toast.success('Live preview runtime settings updated'); - }, - onError: () => { - toast.error('Failed to update live preview runtime settings'); - }, - }), - ); - const environmentMutation = useMutation( - trpc.previewSettings.updateEnvironmentPreview.mutationOptions({ - onSuccess: async (result, variables) => { - if (!result.success) { - toast.error(result.error); - return; - } - - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.previewSettings.get.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.environments.list.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.environments.byId.queryKey({ - id: variables.environmentId, - }), - }), - ]); - toast.success('Environment live preview settings updated'); - }, - onError: () => { - toast.error('Failed to update environment live preview settings'); - }, - }), - ); - const effectivePreviewOriginValue = - settingsQuery.data?.effectiveConfig.previewProxyBaseUrl ?? null; - - useEffect(() => { - if (effectivePreviewOriginValue === null) { - return; - } - - setRuntimeDraft({ - previewProxyBaseUrl: effectivePreviewOriginValue, - }); - setFieldErrors({}); - }, [effectivePreviewOriginValue]); - - if (settingsQuery.isPending) { - return ( -
-
-

- Loading live preview settings... -

-
-
- ); - } - - if (!settingsQuery.data) { - return ( -
- - - Live preview settings are unavailable right now. - - -
- ); - } - - const settings = settingsQuery.data; - const effectivePreviewOrigin = settings.effectiveConfig.previewProxyBaseUrl; - const displayedPreviewOrigin = effectivePreviewOrigin || ''; - const previewOriginManagedByEnv = - settings.configSource.previewOriginManagedByEnv; - const runtimeValidation = settings.effectiveConfig.validation; - const previewSetupHost = getPreviewSetupHost(settings.effectiveConfig); - const isLocalSetup = isLocalPreviewDomain(previewSetupHost); - const isSetupReady = runtimeValidation.status === 'pass'; - const setupStatusText = isSetupReady - ? 'Preview setup is ready' - : 'Preview setup needs attention'; - const isRuntimeDirty = - runtimeDraft.previewProxyBaseUrl !== displayedPreviewOrigin; - const previewExampleDisplay = getPreviewExampleDisplay( - settings.effectiveConfig, - ); - const previewExampleHref = settings.effectiveConfig.exampleHostname - ? previewExampleDisplay.startsWith('http') - ? previewExampleDisplay - : `https://${previewExampleDisplay}` - : null; - return ( -
-
-
-
- - deploymentMutation.mutate({ enabled }) - } - disabled={deploymentMutation.isPending} - aria-label="Toggle deployment live previews" - className="mt-1" - /> -
-

- Enable live environment previews - {deploymentMutation.isPending ? ( - - ) : null} -

-

- Publish browser-accessible preview URLs to access environments - within task sandboxes. -

-
-
- - {settings.deployment.previewsEnabled && ( -
-
-
-

Status

-
- -
-

{setupStatusText}

-

- {runtimeValidation.summary} -

-
-
-
- - {runtimeValidation.status === 'fail' && - runtimeValidation.details.length > 0 ? ( -

- - {runtimeValidation.details.join(' ')} -

- ) : null} - - {settings.overrideState.hasOverrides ? ( - - - Runtime environment variables are currently overriding the - saved deployment preview settings. - - - ) : null} - -
-

Configuration

- -
- - 1 - -
- -
- { - setRuntimeDraft((current) => ({ - ...current, - previewProxyBaseUrl: event.target.value, - })); - if (fieldErrors.previewProxyBaseUrl) { - setFieldErrors((current) => ({ - ...current, - previewProxyBaseUrl: undefined, - })); - } - }} - aria-label="Preview origin" - placeholder="eg: https://preview.example.com or http://previews.acme.ai:4000" - /> - - {!previewOriginManagedByEnv && ( - - )} -
- - {fieldErrors.previewProxyBaseUrl ? ( -

- {fieldErrors.previewProxyBaseUrl} -

- ) : null} - - {previewOriginManagedByEnv && ( -

- - Currently defined by PREVIEW_PROXY_BASE_URL, - can't be changed here. -

- )} -
-
- - {isLocalSetup ? null : ( -
- - 2 - -
-

- Create both of the following DNS records -

- -
-
-
- - Base host - - CNAME - - {previewSetupHost ?? 'preview.example.com'} - - -
-
- - Wildcard host - - CNAME - - *.{previewSetupHost ?? 'preview.example.com'} - - -
-
-
    -
  • - Make sure your SSL certificate covers both the - base host and the wildcard host -
  • -
  • - If you need to point to IP addresses, use A/AAAA - records instead of CNAME -
  • -
-
-
-
- )} -
- -
-
- - {isLocalSetup ? '2' : '3'} - -
-

Check your configuration

-
- {previewExampleDisplay} -
- -
- {!isLocalSetup && ( - - )} - - {previewExampleHref ? ( - - ) : ( - - )} -
-
-
-
-
-
- )} -
-
- - {settings.deployment.previewsEnabled && ( -
-
- {settings.environments.length === 0 ? ( -

- You haven't created any environments yet.{' '} - - Create your first - - - . -

- ) : ( - settings.environments.map((environment) => ( - environmentMutation.mutate(input)} - /> - )) - )} -
-
- )} -
- ); -} diff --git a/apps/web/src/components/settings/settings-navigation.ts b/apps/web/src/components/settings/settings-navigation.ts index cb9e7540c..9f20f6bed 100644 --- a/apps/web/src/components/settings/settings-navigation.ts +++ b/apps/web/src/components/settings/settings-navigation.ts @@ -3,7 +3,6 @@ import { Brain, Cpu, FlaskConical, - Globe, GraduationCap, GitMerge, IdCard, @@ -20,7 +19,6 @@ export type SettingsPageId = | 'personal' | 'users' | 'environments' - | 'previews' | 'agent-guidance' | 'automations' | 'integrations' @@ -124,16 +122,6 @@ const SETTINGS_NAVIGATION_ITEMS: SettingsNavigationItem[] = [ newGroup: true, matches: (pathname) => pathname.startsWith(SETTINGS_PATHS.environments), }, - { - id: 'previews', - label: 'Live Previews', - title: 'Live Previews', - description: 'Enable live previews of the results of Roomote tasks.', - href: SETTINGS_PATHS.previews, - icon: Globe, - adminOnly: true, - matches: (pathname) => pathname.startsWith(SETTINGS_PATHS.previews), - }, { id: 'agent-guidance', label: 'Agent Guidance', diff --git a/apps/web/src/lib/environment-definition.ts b/apps/web/src/lib/environment-definition.ts index 137fd4607..d444b00b9 100644 --- a/apps/web/src/lib/environment-definition.ts +++ b/apps/web/src/lib/environment-definition.ts @@ -128,6 +128,39 @@ When validation is sufficient, update the existing environment using the ${PRODU Do not create a duplicate environment.`; } +/** + * Prompt for the preview pane's "fix previews with an agent" action. Unlike + * the definition prompts above, this deliberately does NOT invoke the + * `$environment-setup` skill: that workflow prohibits application source + * changes, and app-level fixes (framing headers, CORS, allowed hosts) are + * exactly what broken previews often need. The task runs inside the + * environment, so it can verify fixes end-to-end through the public preview + * origin. + */ +export function buildEnvironmentPreviewRepairPrompt(input: { + environmentId: string; + environmentName: string; + config: EnvironmentConfig; +}): string { + return `Fix live previews for the ${PRODUCT_NAME} environment "${input.environmentName}" (id ${input.environmentId}). + +Live previews are configured for this environment, but the user reports the preview does not load or work correctly behind the preview proxy. You are running inside the environment, so its commands and services have already started, and each configured port's public preview origin is available in the sandbox as \`ROOMOTE__HOST\`. + +Current environment YAML: +\`\`\`yaml +${configToYaml(input.config).trim()} +\`\`\` + +1. Reproduce: check each configured port's surface on localhost, then through its public preview origin from \`ROOMOTE__HOST\`. Compare the two to isolate proxy-specific failures. +2. Diagnose the common causes: dev servers that reject unknown hosts (allowed-hosts or host-header checks) or listen only on a loopback interface, hardcoded localhost or 127.0.0.1 origins in client code or API calls, CORS failures on cross-origin API requests, response headers that block framing (\`X-Frame-Options\`, \`Content-Security-Policy\` \`frame-ancestors\`), and websocket or HMR endpoints that bypass the proxy. +3. Fix the root cause: + - For environment-definition problems (commands, env vars, port settings, services, docker projects), update the environment using the ${PRODUCT_NAME} MCP tool \`manage_environments\` with \`action: "update"\` and \`environmentId: "${input.environmentId}"\`. Keep every other environment setting unchanged. + - For application code problems, make the smallest correct code change, restart the affected service so the running sandbox picks it up, and open a pull request with the change. +4. Verify end-to-end before declaring success: request the preview origin again from inside the sandbox after your fix and confirm it responds with the expected content and no framing or CORS blockers. + +If the fix requires credentials, external infrastructure, or a decision you cannot safely make, report that blocker clearly instead of guessing.`; +} + function environmentIncludesRepositorySet( config: EnvironmentConfig, selectedRepositoryFullNames: string[], diff --git a/apps/web/src/lib/server/auth-context.ts b/apps/web/src/lib/server/auth-context.ts index 25c3470d6..7f3529fcc 100644 --- a/apps/web/src/lib/server/auth-context.ts +++ b/apps/web/src/lib/server/auth-context.ts @@ -6,7 +6,6 @@ import { evaluateFeatureFlagsFromMetadata, isAnonymousAnalyticsEnabledFromMetadata, normalizeMetadataRecord, - setDeploymentPreviewsEnabled, } from '@roomote/feature-flags'; import { isTelemetryEnvAllowed } from '@roomote/telemetry/server'; @@ -104,7 +103,7 @@ async function ensureDeploymentIdentity({ existingDeploymentSettings.metadata, ); } else { - deploymentMetadata = setDeploymentPreviewsEnabled({}, false); + deploymentMetadata = {}; await db.insert(deploymentSettings).values({ id: DEFAULT_DEPLOYMENT_ID, diff --git a/apps/web/src/lib/settings.ts b/apps/web/src/lib/settings.ts index fafad9633..7ad903785 100644 --- a/apps/web/src/lib/settings.ts +++ b/apps/web/src/lib/settings.ts @@ -4,7 +4,6 @@ export const SETTINGS_PATHS = { users: '/settings/users', environments: '/settings/environments', sourceControl: '/settings/source-control', - previews: '/settings/previews', automations: '/automations', agentGuidance: '/settings/agent-guidance', integrations: '/settings/integrations', diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index b67136c2f..368911558 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -858,8 +858,42 @@ async function getActiveVerificationTaskId( dbOrTx: DatabaseOrTransaction, environmentId: string, ): Promise { + return ( + (await getActiveEnvironmentAgentTask(dbOrTx, environmentId))?.taskId ?? null + ); +} + +/** + * Find the active agent task working on the given environment, if any: either + * a retry-launched verification task (`verifiesEnvironmentId`) or a + * setup/definition task (workflow `setup_onboarding` with + * `environmentDefinitionId`). Shared by the verification-retry guard and the + * preview-setup CTA so neither launches a duplicate agent while one is + * in flight. + * + * `isPreviewSetupTask` distinguishes preview setup/repair agents (definition + * tasks that run inside the environment, i.e. payload `environmentId` matches) + * from the initial environment-creation task, which is necessarily repo-only, + * and from verification retries. + */ +export async function getActiveEnvironmentAgentTask( + dbOrTx: DatabaseOrTransaction, + environmentId: string, +): Promise<{ + taskId: string; + status: RunStatus; + isPreviewSetupTask: boolean; +} | null> { const [job] = await dbOrTx - .select({ taskId: taskRuns.taskId }) + .select({ + taskId: taskRuns.taskId, + status: taskRuns.status, + isPreviewSetupTask: sql`( + ${tasks.workflow} = 'setup_onboarding' + AND ${taskRuns.payload} ->> 'environmentDefinitionId' = ${environmentId} + AND ${taskRuns.payload} ->> 'environmentId' = ${environmentId} + )`, + }) .from(taskRuns) .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) .where( @@ -877,7 +911,7 @@ async function getActiveVerificationTaskId( .orderBy(desc(taskRuns.createdAt), desc(taskRuns.id)) .limit(1); - return job?.taskId ?? null; + return job ?? null; } /** diff --git a/apps/web/src/trpc/commands/preview-settings/index.ts b/apps/web/src/trpc/commands/preview-settings/index.ts index 78dcf5ff0..0c9694576 100644 --- a/apps/web/src/trpc/commands/preview-settings/index.ts +++ b/apps/web/src/trpc/commands/preview-settings/index.ts @@ -1,40 +1,41 @@ import { Env } from '@/lib/server'; import type { UserAuthSuccess } from '@/types'; +import { enqueueTask } from '@roomote/cloud-agents/server'; import { + and, db, - deploymentSettings, + environments, eq, + isNull, resolveEffectivePreviewRuntimeConfig, + taskRuns, + withEnvironmentVerificationRetryLock, } from '@roomote/db/server'; import { - areDeploymentPreviewsEnabled, - normalizeMetadataRecord, - setDeploymentPreviewsEnabled, -} from '@roomote/feature-flags'; -import { + ALL_REPOSITORIES, analyzePreviewRuntimeConfig, + appendEnvironmentDefinitionGuidance, buildExamplePreviewHostname, deriveRoomotePreviewDomain, - hasAdvancedPreviewConfig, + ENVIRONMENT_PREVIEW_SETUP_CHANGE_REQUEST, hasConfiguredPreviewPorts, - isEnvironmentPreviewEnabledInConfig, + INTERNAL_PORTS, isLocalPreviewDomain, - type EnvironmentConfig, - type NamedPort, PREVIEW_DOMAIN_ENV_VAR, PREVIEW_PROXY_BASE_URL_ENV_VAR, + TaskPayloadKind, + type EnvironmentConfig, type PreviewRuntimeConfigFields, + type RunStatus, } from '@roomote/types'; import { - getEnvironmentByIdCommand, - getEnvironmentsCommand, - updateEnvironmentCommand, - type EnvironmentWithMeta, -} from '../environments'; -import { upsertDeploymentEnvironmentVariables } from '../environment-variables'; + buildEnvironmentPreviewRepairPrompt, + buildUpdateEnvironmentDefinitionPrompt, +} from '@/lib/environment-definition'; -const DEFAULT_DEPLOYMENT_ID = 'default'; +import { getActiveEnvironmentAgentTask } from '../environments'; +import { upsertDeploymentEnvironmentVariables } from '../environment-variables'; function assertAdmin(auth: UserAuthSuccess) { if (!auth.isAdmin) { @@ -55,36 +56,15 @@ type RuntimePreviewValidation = { }; export type PreviewSettingsStatus = - | 'disabled' - | 'configured_but_off' | 'missing_runtime_config' | 'ready' | 'validation_failed'; -export interface PreviewSettingsEnvironmentSummary { - id: string; - name: string; - description: string | null; - config: Pick; - previewState: { - status: - | 'ready' - | 'deployment_disabled' - | 'runtime_unavailable' - | 'environment_disabled' - | 'not_configured'; - label: string; - }; - hasAdvancedPreviewConfig: boolean; - primaryPortName: string | null; -} - export interface PreviewSettingsSnapshot { - deployment: { - previewsEnabled: boolean; + runtime: { status: PreviewSettingsStatus; statusLabel: string; - effectiveAvailability: boolean; + ready: boolean; }; persistedConfig: { previewProxyBaseUrl: string; @@ -109,11 +89,17 @@ export interface PreviewSettingsSnapshot { previewOrigin: 'env' | 'deployment' | 'default' | 'missing'; previewOriginManagedByEnv: boolean; }; - environments: PreviewSettingsEnvironmentSummary[]; } const REMOTE_PREVIEW_UI_MOCK_ENV_VAR = 'MOCK_LIVE_PREVIEWS_REMOTE_DOMAIN'; +function isPreviewRuntimeUiMockActive(): boolean { + return ( + process.env.NODE_ENV !== 'production' && + Boolean(process.env[REMOTE_PREVIEW_UI_MOCK_ENV_VAR]?.trim()) + ); +} + export function applyPreviewRuntimeUiMock( runtime: PreviewSettingsSnapshot['effectiveConfig'], ): PreviewSettingsSnapshot['effectiveConfig'] { @@ -145,40 +131,6 @@ export function applyPreviewRuntimeUiMock( }; } -function buildEnvironmentPreviewState(params: { - environment: EnvironmentWithMeta; - deploymentEnabled: boolean; - runtimeReady: boolean; -}): PreviewSettingsEnvironmentSummary['previewState'] { - const { environment, deploymentEnabled, runtimeReady } = params; - - if (!hasConfiguredPreviewPorts(environment.config)) { - return { status: 'not_configured', label: 'Not configured' }; - } - - if (!deploymentEnabled) { - return { status: 'deployment_disabled', label: 'Configured but off' }; - } - - if (!runtimeReady) { - return { status: 'runtime_unavailable', label: 'Unavailable' }; - } - - if (!isEnvironmentPreviewEnabledInConfig(environment.config)) { - return { status: 'environment_disabled', label: 'Disabled' }; - } - - return { status: 'ready', label: 'Ready' }; -} - -function buildPrimaryPortName(ports: NamedPort[] | undefined): string | null { - if (!ports?.length) { - return null; - } - - return ports.find((port) => port.primary)?.name ?? ports[0]?.name ?? null; -} - async function probePreviewHostname( previewProxyBaseUrl: string, hostname: string, @@ -289,37 +241,20 @@ async function validateRuntimePreviewConfig(): Promise< }); } -function buildDeploymentStatus(params: { - deploymentEnabled: boolean; - runtimeValidation: RuntimePreviewValidation; -}): { status: PreviewSettingsStatus; statusLabel: string } { - const { deploymentEnabled, runtimeValidation } = params; - - if (!deploymentEnabled) { - if (runtimeValidation.status === 'pass') { - return { - status: 'configured_but_off', - statusLabel: 'Configured but off', - }; - } - - return { status: 'disabled', statusLabel: 'Disabled' }; - } - - if (runtimeValidation.status === 'fail') { - return { - status: - runtimeValidation.reason === 'missing_runtime_config' - ? 'missing_runtime_config' - : 'validation_failed', - statusLabel: - runtimeValidation.reason === 'missing_runtime_config' - ? 'Missing runtime config' - : 'Validation failed', - }; +function buildRuntimeStatus(validation: RuntimePreviewValidation): { + status: PreviewSettingsStatus; + statusLabel: string; +} { + if (validation.status === 'pass') { + return { status: 'ready', statusLabel: 'Ready' }; } - return { status: 'ready', statusLabel: 'Ready' }; + return validation.reason === 'missing_runtime_config' + ? { + status: 'missing_runtime_config', + statusLabel: 'Missing runtime config', + } + : { status: 'validation_failed', statusLabel: 'Validation failed' }; } export async function getPreviewSettingsCommand( @@ -327,35 +262,22 @@ export async function getPreviewSettingsCommand( ): Promise { assertAdmin(auth); - const [deployment, environmentList, resolvedConfig, effectiveConfig] = - await Promise.all([ - db.query.deploymentSettings.findFirst({ - where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), - columns: { metadata: true }, - }), - getEnvironmentsCommand(auth), - resolveEffectivePreviewRuntimeConfig({ - runtimeEnv: process.env, - defaultPreviewProxyBaseUrl: Env.PREVIEW_PROXY_BASE_URL, - defaultPreviewDomains: Env.PREVIEW_DOMAINS, - }), - validateRuntimePreviewConfig(), - ]); - - const metadata = normalizeMetadataRecord(deployment?.metadata); - const deploymentEnabled = areDeploymentPreviewsEnabled(metadata); - const deploymentStatus = buildDeploymentStatus({ - deploymentEnabled, - runtimeValidation: effectiveConfig.validation, - }); - const runtimeReady = effectiveConfig.validation.status === 'pass'; + const [resolvedConfig, effectiveConfig] = await Promise.all([ + resolveEffectivePreviewRuntimeConfig({ + runtimeEnv: process.env, + defaultPreviewProxyBaseUrl: Env.PREVIEW_PROXY_BASE_URL, + defaultPreviewDomains: Env.PREVIEW_DOMAINS, + }), + validateRuntimePreviewConfig(), + ]); + + const runtimeStatus = buildRuntimeStatus(effectiveConfig.validation); return { - deployment: { - previewsEnabled: deploymentEnabled, - status: deploymentStatus.status, - statusLabel: deploymentStatus.statusLabel, - effectiveAvailability: deploymentEnabled && runtimeReady, + runtime: { + status: runtimeStatus.status, + statusLabel: runtimeStatus.statusLabel, + ready: effectiveConfig.validation.status === 'pass', }, persistedConfig: { previewProxyBaseUrl: resolvedConfig.persisted.previewProxyBaseUrl ?? '', @@ -373,61 +295,9 @@ export async function getPreviewSettingsCommand( previewOriginManagedByEnv: resolvedConfig.sourceState.previewProxyBaseUrlManagedByRuntimeEnv, }, - environments: environmentList.map((environment) => ({ - id: environment.id, - name: environment.name, - description: environment.description, - config: { - ports: environment.config.ports, - previews_enabled: environment.config.previews_enabled, - }, - previewState: buildEnvironmentPreviewState({ - environment, - deploymentEnabled, - runtimeReady, - }), - hasAdvancedPreviewConfig: (environment.config.ports ?? []).some((port) => - hasAdvancedPreviewConfig(port), - ), - primaryPortName: buildPrimaryPortName(environment.config.ports), - })), }; } -export async function setDeploymentPreviewEnabledCommand( - auth: UserAuthSuccess, - input: { enabled: boolean }, -) { - assertAdmin(auth); - - const existing = await db.query.deploymentSettings.findFirst({ - where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), - columns: { metadata: true }, - }); - - const metadata = setDeploymentPreviewsEnabled( - existing?.metadata, - input.enabled, - ); - - await db - .insert(deploymentSettings) - .values({ - id: DEFAULT_DEPLOYMENT_ID, - metadata, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: deploymentSettings.id, - set: { - metadata, - updatedAt: new Date(), - }, - }); - - return getPreviewSettingsCommand(auth); -} - type PreviewRuntimeConfigFieldErrors = Partial< Record<'previewProxyBaseUrl', string> >; @@ -543,34 +413,234 @@ export async function updatePreviewRuntimeConfigCommand( }; } -export async function updateEnvironmentPreviewCommand( +export interface TaskPreviewStatus { + runtimeReady: boolean; + environment: { + id: string; + name: string; + hasConfiguredPorts: boolean; + portNames: string[]; + } | null; + runHasPreviewDomains: boolean; + /** + * Active agent task for the environment. Kind 'preview' is a preview + * setup/repair agent launched from the preview pane; 'environment' is any + * other environment agent (initial creation, verification retry). + * + * `taskId` is null for non-admin viewers: these tasks embed the full + * environment YAML (including secret-capable fields) in their description, + * so linking members to them would bypass the admin-only environment + * settings boundary. + */ + setupTask: { + taskId: string | null; + status: RunStatus; + kind: 'preview' | 'environment'; + } | null; +} + +async function resolveLatestTaskRunForTask(taskId: string) { + return db.query.taskRuns.findFirst({ + columns: { payload: true, machineDomains: true }, + where: eq(taskRuns.taskId, taskId), + orderBy: (table, { desc }) => [desc(table.createdAt)], + }); +} + +function getEnvironmentIdFromPayload(payload: unknown): string | null { + const environmentId = (payload as { environmentId?: unknown } | undefined) + ?.environmentId; + + return typeof environmentId === 'string' && environmentId.length > 0 + ? environmentId + : null; +} + +/** + * Whether preview infrastructure is ready, matching the controller-side gate + * that decides if environment ports publish preview domains. Deliberately skips + * the DNS probe so this stays cheap enough to poll from the task page. + */ +async function isPreviewRuntimeReady(): Promise { + if (isPreviewRuntimeUiMockActive()) { + return true; + } + + const resolvedConfig = await resolveEffectivePreviewRuntimeConfig({ + runtimeEnv: process.env, + defaultPreviewProxyBaseUrl: Env.PREVIEW_PROXY_BASE_URL, + defaultPreviewDomains: Env.PREVIEW_DOMAINS, + }); + + return resolvedConfig.analysis.isReady; +} + +async function loadDeploymentEnvironment(environmentId: string) { + const [environment] = await db + .select({ + id: environments.id, + name: environments.name, + config: environments.config, + }) + .from(environments) + .where(and(eq(environments.id, environmentId), isNull(environments.userId))) + .limit(1); + + return environment + ? { ...environment, config: environment.config as EnvironmentConfig } + : null; +} + +/** + * Preview availability for a task, safe for non-admin viewers: exposes only + * the environment id/name, port names, and readiness booleans. Admin-only + * infrastructure details stay behind `getPreviewSettingsCommand`. + */ +export async function getTaskPreviewStatusCommand( auth: UserAuthSuccess, - input: { - environmentId: string; - previewsEnabled?: boolean; - ports?: NamedPort[]; - }, -) { + input: { taskId: string }, +): Promise { + const [taskRun, runtimeReady] = await Promise.all([ + resolveLatestTaskRunForTask(input.taskId), + isPreviewRuntimeReady(), + ]); + + const environmentId = getEnvironmentIdFromPayload(taskRun?.payload); + const runHasPreviewDomains = Object.keys(taskRun?.machineDomains ?? {}).some( + (portName) => !INTERNAL_PORTS.has(portName), + ); + + if (!environmentId) { + return { + runtimeReady, + environment: null, + runHasPreviewDomains, + setupTask: null, + }; + } + + const [environment, activeAgentTask] = await Promise.all([ + loadDeploymentEnvironment(environmentId), + getActiveEnvironmentAgentTask(db, environmentId), + ]); + + return { + runtimeReady, + environment: environment + ? { + id: environment.id, + name: environment.name, + hasConfiguredPorts: hasConfiguredPreviewPorts(environment.config), + portNames: (environment.config.ports ?? []).map((port) => port.name), + } + : null, + runHasPreviewDomains, + setupTask: activeAgentTask + ? { + taskId: auth.isAdmin ? activeAgentTask.taskId : null, + status: activeAgentTask.status, + kind: activeAgentTask.isPreviewSetupTask ? 'preview' : 'environment', + } + : null, + }; +} + +type PreviewSetupTaskMode = 'configure' | 'repair'; + +/** + * Launch an environment-setup agent focused on getting live previews working + * for the task's environment. Admin-only, matching the rest of environment + * management. The environment id is derived server-side from the task run, so + * callers cannot target arbitrary environments. If an agent is already + * working on the environment, returns that task instead of launching a + * duplicate. + * + * Mode 'configure' (default) adds preview ports to an environment without + * them; mode 'repair' diagnoses a configured preview that does not load or + * work correctly behind the preview proxy. + */ +export async function startPreviewSetupTaskCommand( + auth: UserAuthSuccess, + input: { taskId: string; mode?: PreviewSetupTaskMode }, +): Promise<{ taskId: string; alreadyRunning: boolean }> { assertAdmin(auth); - const environment = await getEnvironmentByIdCommand(auth, { - id: input.environmentId, - }); + const mode: PreviewSetupTaskMode = input.mode ?? 'configure'; + + const taskRun = await resolveLatestTaskRunForTask(input.taskId); + const environmentId = getEnvironmentIdFromPayload(taskRun?.payload); + + if (!environmentId) { + throw new Error( + 'Live preview setup is only available for environment-backed tasks.', + ); + } + + const environment = await loadDeploymentEnvironment(environmentId); if (!environment) { - return { success: false as const, error: 'Environment not found' }; + throw new Error('Environment not found'); } - const nextConfig: EnvironmentConfig = { - ...environment.config, - ...(input.previewsEnabled !== undefined - ? { previews_enabled: input.previewsEnabled } - : {}), - ...(input.ports !== undefined ? { ports: input.ports } : {}), - }; + const repositoryFullNames = environment.config.repositories.map( + (repository) => repository.repository, + ); + + // Repair mode deliberately skips the $environment-setup skill: that + // workflow prohibits application source changes, and broken previews often + // need exactly those (framing headers, CORS, allowed hosts). The repair + // prompt is a standalone coding-task brief that can fix env config through + // the manage_environments MCP action, fix app code via a PR, and verify + // through the public preview origin. + const prompt = + mode === 'repair' + ? buildEnvironmentPreviewRepairPrompt({ + environmentId: environment.id, + environmentName: environment.name, + config: environment.config, + }) + : appendEnvironmentDefinitionGuidance( + buildUpdateEnvironmentDefinitionPrompt({ + environmentId: environment.id, + environmentName: environment.name, + repositoryFullNames, + config: environment.config, + }), + ENVIRONMENT_PREVIEW_SETUP_CHANGE_REQUEST, + 'Requested changes from the user:', + ); + + return withEnvironmentVerificationRetryLock(environment.id, async (tx) => { + const activeTask = await getActiveEnvironmentAgentTask(tx, environment.id); + + if (activeTask) { + return { taskId: activeTask.taskId, alreadyRunning: true }; + } + + const launchResult = await enqueueTask({ + title: + mode === 'repair' + ? `Fix live previews: ${environment.name}` + : `Set up live previews: ${environment.name}`, + task: { + type: TaskPayloadKind.StandardTask, + // Runs inside the environment (like verification tasks) so the agent + // validates the actually-running services, and so this task is + // distinguishable from the initial repo-only environment-creation + // task when reporting preview setup progress. + payload: { + repo: ALL_REPOSITORIES, + environmentId: environment.id, + environmentDefinitionId: environment.id, + description: prompt, + }, + }, + initiator: { kind: 'user', userId: auth.userId }, + workflow: 'setup_onboarding', + surface: 'web', + trigger: 'manual', + }); - return updateEnvironmentCommand(auth, { - id: input.environmentId, - config: nextConfig, + return { taskId: launchResult.taskId, alreadyRunning: false }; }); } diff --git a/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts b/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts new file mode 100644 index 000000000..966ec4d5c --- /dev/null +++ b/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts @@ -0,0 +1,341 @@ +import { + db, + environmentFactory, + runFactory, + taskFactory, + taskRuns, + eq, +} from '@roomote/db/server'; +import { + ENVIRONMENT_PREVIEW_SETUP_CHANGE_REQUEST, + RunStatus, +} from '@roomote/types'; +import { enqueueTask } from '@roomote/cloud-agents/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { + getTaskPreviewStatusCommand, + startPreviewSetupTaskCommand, +} from './index'; + +vi.mock('@roomote/cloud-agents/server', () => ({ + enqueueTask: vi.fn(() => { + throw new Error('enqueueTask should not be called in these tests'); + }), +})); + +const auth = { userId: 'test-user', isAdmin: false } as UserAuthSuccess; +const adminAuth = { userId: 'test-admin', isAdmin: true } as UserAuthSuccess; + +async function createEnvironmentBackedTask(params: { + environmentId: string; + machineDomains?: Record; +}) { + const task = await taskFactory.create({ workflow: 'standard' }); + const run = await runFactory.create({ + taskId: task.id, + payload: { + repo: 'test/repo', + environmentId: params.environmentId, + description: 'work', + } as never, + }); + + if (params.machineDomains) { + await db + .update(taskRuns) + .set({ machineDomains: params.machineDomains }) + .where(eq(taskRuns.id, run.id)); + } + + return task; +} + +async function createActiveSetupTask( + environmentId: string, + options: { runsInEnvironment?: boolean } = {}, +) { + const task = await taskFactory.create({ workflow: 'setup_onboarding' }); + const run = await runFactory.create({ + taskId: task.id, + payload: { + repo: 'test/repo', + environmentDefinitionId: environmentId, + ...(options.runsInEnvironment ? { environmentId } : {}), + description: 'setup', + } as never, + }); + await db + .update(taskRuns) + .set({ status: RunStatus.Running }) + .where(eq(taskRuns.id, run.id)); + return task; +} + +describe('getTaskPreviewStatusCommand', () => { + it('returns a null environment for repo-only tasks', async () => { + const task = await taskFactory.create({ workflow: 'standard' }); + await runFactory.create({ + taskId: task.id, + payload: { repo: 'test/repo', description: 'work' } as never, + }); + + const status = await getTaskPreviewStatusCommand(auth, { + taskId: task.id, + }); + + expect(status.environment).toBeNull(); + expect(status.runHasPreviewDomains).toBe(false); + expect(status.setupTask).toBeNull(); + }); + + it('reports configured ports and their names for environment-backed tasks', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + config: { + name: 'Env', + repositories: [{ repository: 'test/repo' }], + ports: [{ name: 'WEB', port: 3000, primary: true }], + } as never, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + + const status = await getTaskPreviewStatusCommand(auth, { + taskId: task.id, + }); + + expect(status.environment).toMatchObject({ + id: environment.id, + hasConfiguredPorts: true, + portNames: ['WEB'], + }); + }); + + it('reports missing ports without leaking environment config', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + + const status = await getTaskPreviewStatusCommand(auth, { + taskId: task.id, + }); + + expect(status.environment?.hasConfiguredPorts).toBe(false); + expect(status.environment?.portNames).toEqual([]); + expect(status.environment).not.toHaveProperty('config'); + }); + + it('ignores internal-only machine domains when reporting preview domains', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + machineDomains: { SANDBOX_SERVER: 'internal.example.com' }, + }); + + const status = await getTaskPreviewStatusCommand(auth, { + taskId: task.id, + }); + + expect(status.runHasPreviewDomains).toBe(false); + }); + + it('reports preview domains when the run exposes a user-facing port', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + machineDomains: { + SANDBOX_SERVER: 'internal.example.com', + WEB: 'task-web.preview.example.com', + }, + }); + + const status = await getTaskPreviewStatusCommand(auth, { + taskId: task.id, + }); + + expect(status.runHasPreviewDomains).toBe(true); + }); + + it('classifies the initial repo-only environment setup task as an environment agent', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + const setupTask = await createActiveSetupTask(environment.id); + + const status = await getTaskPreviewStatusCommand(adminAuth, { + taskId: task.id, + }); + + expect(status.setupTask).toEqual({ + taskId: setupTask.id, + status: RunStatus.Running, + kind: 'environment', + }); + }); + + it('classifies an environment-running definition task as a preview agent', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + const setupTask = await createActiveSetupTask(environment.id, { + runsInEnvironment: true, + }); + + const status = await getTaskPreviewStatusCommand(adminAuth, { + taskId: task.id, + }); + + expect(status.setupTask).toEqual({ + taskId: setupTask.id, + status: RunStatus.Running, + kind: 'preview', + }); + }); + + it('omits the agent task id for non-admin viewers', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + await createActiveSetupTask(environment.id, { runsInEnvironment: true }); + + const status = await getTaskPreviewStatusCommand(auth, { + taskId: task.id, + }); + + expect(status.setupTask).toEqual({ + taskId: null, + status: RunStatus.Running, + kind: 'preview', + }); + }); +}); + +describe('startPreviewSetupTaskCommand', () => { + it('rejects non-admin callers', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + + await expect( + startPreviewSetupTaskCommand(auth, { taskId: task.id }), + ).rejects.toThrow('Unauthorized'); + }); + + it('rejects repo-only tasks', async () => { + const task = await taskFactory.create({ workflow: 'standard' }); + await runFactory.create({ + taskId: task.id, + payload: { repo: 'test/repo', description: 'work' } as never, + }); + + await expect( + startPreviewSetupTaskCommand(adminAuth, { taskId: task.id }), + ).rejects.toThrow( + 'Live preview setup is only available for environment-backed tasks.', + ); + }); + + it('returns the in-flight setup task instead of launching a duplicate', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + const setupTask = await createActiveSetupTask(environment.id); + + await expect( + startPreviewSetupTaskCommand(adminAuth, { taskId: task.id }), + ).resolves.toEqual({ + taskId: setupTask.id, + alreadyRunning: true, + }); + }); + + it('launches with the repair change request in repair mode', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + vi.mocked(enqueueTask).mockResolvedValueOnce({ + taskId: 'launched-task', + id: 999, + } as never); + + await expect( + startPreviewSetupTaskCommand(adminAuth, { + taskId: task.id, + mode: 'repair', + }), + ).resolves.toEqual({ taskId: 'launched-task', alreadyRunning: false }); + + const enqueueInput = vi.mocked(enqueueTask).mock.calls.at(-1)?.[0] as { + title: string; + task: { payload: { description: string; environmentId?: string } }; + }; + expect(enqueueInput.title).toMatch(/^Fix live previews: /); + expect(enqueueInput.task.payload.environmentId).toBe(environment.id); + // Repair prompts are standalone coding-task briefs, not $environment-setup + // skill invocations: the skill prohibits application source changes. + expect( + enqueueInput.task.payload.description.startsWith('$environment-setup'), + ).toBe(false); + expect(enqueueInput.task.payload.description).toContain( + 'behind the preview proxy', + ); + expect(enqueueInput.task.payload.description).toContain( + 'manage_environments', + ); + }); + + it('launches with the setup change request by default', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + }); + const task = await createEnvironmentBackedTask({ + environmentId: environment.id, + }); + vi.mocked(enqueueTask).mockResolvedValueOnce({ + taskId: 'launched-task-2', + id: 1000, + } as never); + + await expect( + startPreviewSetupTaskCommand(adminAuth, { taskId: task.id }), + ).resolves.toEqual({ taskId: 'launched-task-2', alreadyRunning: false }); + + const enqueueInput = vi.mocked(enqueueTask).mock.calls.at(-1)?.[0] as { + title: string; + task: { payload: { description: string; environmentId?: string } }; + }; + expect(enqueueInput.title).toMatch(/^Set up live previews: /); + expect(enqueueInput.task.payload.environmentId).toBe(environment.id); + expect(enqueueInput.task.payload.description).toContain( + ENVIRONMENT_PREVIEW_SETUP_CHANGE_REQUEST, + ); + }); +}); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 330f765cf..61cfce496 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -11,7 +11,6 @@ import { computeProviders, environmentConfigSchema, ENVIRONMENT_DEFINITION_SETUP_GUIDANCE_MAX_LENGTH, - namedPortSchema, REASONING_EFFORT_VALUES, isTriggerableBackgroundAutomationKey, SCHEDULE_ONLY_BACKGROUND_AUTOMATION_IDS, @@ -154,9 +153,9 @@ import { } from '../commands/environments'; import { getPreviewSettingsCommand, - setDeploymentPreviewEnabledCommand, + getTaskPreviewStatusCommand, + startPreviewSetupTaskCommand, updatePreviewRuntimeConfigCommand, - updateEnvironmentPreviewCommand, } from '../commands/preview-settings'; import { getAuthTokenCommand, @@ -1212,12 +1211,6 @@ export const appRouter = createRouter({ getPreviewSettingsCommand(auth), ), - setDeploymentEnabled: protectedProcedure - .input(z.object({ enabled: z.boolean() })) - .mutation(({ ctx: { auth }, input }) => - setDeploymentPreviewEnabledCommand(auth, input), - ), - updateRuntimeConfig: protectedProcedure .input( z.object({ @@ -1228,16 +1221,21 @@ export const appRouter = createRouter({ updatePreviewRuntimeConfigCommand(auth, input), ), - updateEnvironmentPreview: protectedProcedure + taskStatus: protectedProcedure + .input(z.object({ taskId: z.string() })) + .query(({ ctx: { auth }, input }) => + getTaskPreviewStatusCommand(auth, input), + ), + + startSetupTask: protectedProcedure .input( z.object({ - environmentId: z.string().uuid(), - previewsEnabled: z.boolean().optional(), - ports: z.array(namedPortSchema).optional(), + taskId: z.string(), + mode: z.enum(['configure', 'repair']).optional(), }), ) .mutation(({ ctx: { auth }, input }) => - updateEnvironmentPreviewCommand(auth, input), + startPreviewSetupTaskCommand(auth, input), ), }), snapshots: createRouter({ diff --git a/deploy/coolify/README.md b/deploy/coolify/README.md index 614996b80..88f85cfed 100644 --- a/deploy/coolify/README.md +++ b/deploy/coolify/README.md @@ -131,7 +131,7 @@ Notes: explicit env values, they take precedence over the persisted keypairs. - Leave `PREVIEW_PROXY_BASE_URL` and `PREVIEW_DOMAINS` unset unless you enable live previews. Roomote boots without them; previews report as not - configured in **Settings → Live Previews** until set. + configured in the task page's preview pane until set. ## Task execution @@ -215,9 +215,10 @@ wildcard-capable TLS setup on the Coolify proxy: Coolify server and route them to the preview-proxy service. Wildcard HTTPS certificates require a DNS-01 challenge in Coolify's proxy configuration (see Coolify's wildcard/custom-proxy docs); this is the - advanced part and the reason previews stay off by default. -3. Opt in from **Settings → Live Previews**, which validates the wildcard - hostname and enables previews per deployment and environment. + advanced part and the reason previews stay unconfigured by default. +3. Open any task's preview pane as an admin to validate the wildcard + hostname. Previews are always enabled and publish for every environment + that defines preview ports. ## Upgrades, backups, and costs diff --git a/deploy/fly/README.md b/deploy/fly/README.md index e8a26779a..b3a3098b3 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -183,7 +183,7 @@ Notes: explicit values, they take precedence over the persisted keypairs. - Leave `PREVIEW_PROXY_BASE_URL` and `PREVIEW_DOMAINS` unset unless you enable live previews. Roomote boots without them; previews report as not - configured in **Settings → Live Previews** until set. + configured in the task page's preview pane until set. - Changing secrets on a deployed app restarts its Machines; `fly secrets set --stage` defers that to the next deploy. @@ -305,8 +305,9 @@ previews.` and `fly certs add -a -previews `https://previews.` (the `NEXT_PUBLIC_` variant is what the web client uses to build preview links), add the same three values to the previews app, and redeploy both. -4. Opt in from **Settings → Live Previews**, which validates the wildcard - hostname and enables previews per deployment and environment. +4. Open any task's preview pane as an admin to validate the wildcard + hostname. Previews are always enabled and publish for every environment + that defines preview ports. ## Upgrades, backups, and costs diff --git a/deploy/railway/README.md b/deploy/railway/README.md index ceb6f3558..c011995c6 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -257,7 +257,7 @@ Notes: - The three `PREVIEW_*` variables ship **empty** (mark them optional in the Template Composer so the deploy screen does not prompt for them). Roomote and the preview-proxy service boot fine with them empty; previews report - as not configured in **Settings → Live Previews** until the values are + as not configured in the task page's preview pane until the values are filled in on api (see [Enabling live previews](#enabling-live-previews-optional)). - Leave `JOB_AUTH_*` and `PREVIEW_AUTH_*` unset — @@ -383,8 +383,9 @@ domain, which requires a domain you control: services. The other services already reference `${{api.*}}` for all three; the `NEXT_PUBLIC_` variant is what the web client uses to build preview links, so it must reach the `web` service. -3. Opt in from **Settings → Live Previews**, which validates the wildcard - hostname and enables previews per deployment and environment. +3. Open any task's preview pane as an admin to validate the wildcard + hostname. Previews are always enabled and publish for every environment + that defines preview ports. ## Upgrades, backups, and costs diff --git a/deploy/render/README.md b/deploy/render/README.md index 83dd1ce4a..5bee86cf8 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -248,7 +248,7 @@ Notes: explicit env values, they take precedence over the persisted keypairs. - Leave `PREVIEW_PROXY_BASE_URL` and `PREVIEW_DOMAINS` unset unless you enable live previews. Roomote boots without them; previews report as not - configured in **Settings → Live Previews** until set. + configured in the task page's preview pane until set. ## The artifact bucket @@ -338,8 +338,9 @@ Live previews need a wildcard domain, which requires a domain you control: `PREVIEW_DOMAINS=previews.` to the `roomote-shared` environment group. The `NEXT_PUBLIC_` variant is what the web client uses to build preview links, so the `roomote-web` service must have it. -4. Opt in from **Settings → Live Previews**, which validates the wildcard - hostname and enables previews per deployment and environment. +4. Open any task's preview pane as an admin to validate the wildcard + hostname. Previews are always enabled and publish for every environment + that defines preview ports. ## Upgrades, backups, and costs diff --git a/packages/communication/src/thread-reply-footer-context.ts b/packages/communication/src/thread-reply-footer-context.ts index 91505cf84..157cab2f7 100644 --- a/packages/communication/src/thread-reply-footer-context.ts +++ b/packages/communication/src/thread-reply-footer-context.ts @@ -13,7 +13,6 @@ import { buildPreviewProxyUrl, getPrimaryPortFromConfig, hasConfiguredPreviewPorts, - isEnvironmentPreviewEnabledInConfig, portNameToSlug, } from '@roomote/types'; @@ -93,9 +92,8 @@ export async function resolveThreadReplyLinkedPr(params: { * Resolves the shareable live-preview URL for an environment-backed task. * * Returns the preview-proxy URL for the environment's primary named port, or - * `null` for repo-only tasks, environments without configured ports, - * environments with previews disabled, or deployments without a resolvable - * preview-proxy base URL. + * `null` for repo-only tasks, environments without configured ports, or + * deployments without a resolvable preview-proxy base URL. */ export async function resolveThreadReplyLivePreviewUrl( taskId: string | null | undefined, @@ -128,10 +126,7 @@ export async function resolveThreadReplyLivePreviewUrl( where: eq(environments.id, environmentId), }); - if ( - !hasConfiguredPreviewPorts(environment?.config) || - !isEnvironmentPreviewEnabledInConfig(environment?.config) - ) { + if (!hasConfiguredPreviewPorts(environment?.config)) { return null; } diff --git a/packages/db/src/fixtures/seed.ts b/packages/db/src/fixtures/seed.ts index efd224bfa..42c7ae3f9 100644 --- a/packages/db/src/fixtures/seed.ts +++ b/packages/db/src/fixtures/seed.ts @@ -18,23 +18,13 @@ function normalizeMetadataRecord(metadata: unknown): Record { : {}; } -function setDeploymentPreviewsEnabled( - metadata: Record, - enabled: boolean, -) { - return { - ...normalizeMetadataRecord(metadata), - previews_enabled: enabled, - }; -} - async function seed() { console.log('Seeding local Roomote database...'); const now = new Date(); const settings: CreateDeploymentSettings = { id: deploymentSettingsId, - metadata: setDeploymentPreviewsEnabled(deploymentMetadata, false), + metadata: deploymentMetadata, }; const existingSettings = await db.query.deploymentSettings.findFirst({ diff --git a/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts b/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts index 740a9631b..efb192034 100644 --- a/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts +++ b/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts @@ -1,11 +1,8 @@ import { - areDeploymentPreviewsEnabled, coerceToBoolean, evaluateFeatureFlagFromMetadataSources, evaluateFeatureFlagsFromMetadata, - getDeploymentPreviewsEnabledSetting, normalizeMetadataRecord, - setDeploymentPreviewsEnabled, } from '../index'; import { FeatureFlag } from '../types'; @@ -82,28 +79,6 @@ describe('coerceToBoolean', () => { expect(flags[FeatureFlag.SuggestionRouting]).toBe(true); }); - it('treats live previews as disabled unless explicitly set to true', () => { - expect(areDeploymentPreviewsEnabled({})).toBe(false); - expect(areDeploymentPreviewsEnabled({ previews_enabled: true })).toBe(true); - expect(areDeploymentPreviewsEnabled({ previews_enabled: false })).toBe( - false, - ); - expect( - getDeploymentPreviewsEnabledSetting({ previews_enabled: false }), - ).toBe(false); - expect( - getDeploymentPreviewsEnabledSetting({ previews_enabled: 'false' }), - ).toBeUndefined(); - }); - - it('normalizes and updates deployment preview metadata safely', () => { - expect(normalizeMetadataRecord(null)).toEqual({}); - expect(setDeploymentPreviewsEnabled({ chore_queue: true }, false)).toEqual({ - chore_queue: true, - previews_enabled: false, - }); - }); - it('evaluates all flags from invalid metadata as defaults', () => { const flags = evaluateFeatureFlagsFromMetadata(['suggestion_routing']); diff --git a/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts b/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts index b3278a29e..9a81169c3 100644 --- a/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts +++ b/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts @@ -25,16 +25,18 @@ describe('getFeatureFlagDescriptionByMetadataKey', () => { }); }); - it('returns descriptions for active boolean metadata controls outside the typed flag enum', () => { + it('treats the retired previews_enabled deployment control as legacy', () => { expect(getFeatureFlagDescriptionByMetadataKey('previews_enabled')).toBe( - 'Allow human-facing live preview ports to publish when runtime preview infrastructure is configured', + null, ); expect(getBooleanMetadataDescriptorByKey('previews_enabled')).toEqual({ - kind: 'deployment-control', - description: - 'Allow human-facing live preview ports to publish when runtime preview infrastructure is configured', + kind: 'legacy', + description: null, group: null, }); + }); + + it('returns descriptions for active boolean metadata controls outside the typed flag enum', () => { expect(getFeatureFlagDescriptionByMetadataKey('deployment_disabled')).toBe( 'Disable Roomote access and new task launches for this deployment', ); diff --git a/packages/feature-flags/src/config.ts b/packages/feature-flags/src/config.ts index 4a133a895..c60742b45 100644 --- a/packages/feature-flags/src/config.ts +++ b/packages/feature-flags/src/config.ts @@ -76,12 +76,6 @@ export const DEPLOYMENT_METADATA_BOOLEAN_CONFIG: Record< string, MetadataBooleanDescriptor > = { - previews_enabled: { - kind: 'deployment-control', - group: null, - description: - 'Allow human-facing live preview ports to publish when runtime preview infrastructure is configured', - }, deployment_disabled: { kind: 'deployment-control', group: null, diff --git a/packages/feature-flags/src/deployment-previews.ts b/packages/feature-flags/src/deployment-previews.ts index 00f6b5bee..55e18dcd2 100644 --- a/packages/feature-flags/src/deployment-previews.ts +++ b/packages/feature-flags/src/deployment-previews.ts @@ -5,24 +5,3 @@ export function normalizeMetadataRecord(metadata: unknown): MetadataRecord { ? (metadata as MetadataRecord) : {}; } - -export function getDeploymentPreviewsEnabledSetting( - metadata: unknown, -): boolean | undefined { - const value = normalizeMetadataRecord(metadata).previews_enabled; - return typeof value === 'boolean' ? value : undefined; -} - -export function areDeploymentPreviewsEnabled(metadata: unknown): boolean { - return getDeploymentPreviewsEnabledSetting(metadata) === true; -} - -export function setDeploymentPreviewsEnabled( - metadata: unknown, - enabled: boolean, -): MetadataRecord { - return { - ...normalizeMetadataRecord(metadata), - previews_enabled: enabled, - }; -} diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts index e8e726990..60e312f8f 100644 --- a/packages/feature-flags/src/index.ts +++ b/packages/feature-flags/src/index.ts @@ -35,12 +35,7 @@ export { DEPLOYMENT_METADATA_BOOLEAN_CONFIG, FEATURE_FLAG_CONFIG, } from './config'; -export { - areDeploymentPreviewsEnabled, - getDeploymentPreviewsEnabledSetting, - normalizeMetadataRecord, - setDeploymentPreviewsEnabled, -} from './deployment-previews'; +export { normalizeMetadataRecord } from './deployment-previews'; /** * Coerce a value to boolean for boolean flags. diff --git a/packages/feature-flags/src/types.ts b/packages/feature-flags/src/types.ts index 908c316e1..66d436fb1 100644 --- a/packages/feature-flags/src/types.ts +++ b/packages/feature-flags/src/types.ts @@ -95,7 +95,6 @@ export interface MetadataRecord { visual_proof_auto_screencast?: boolean; background_subagents?: boolean; opencode_code_mode?: boolean; - previews_enabled?: boolean; deployment_disabled?: boolean; anonymous_analytics_enabled?: boolean; [key: string]: unknown; diff --git a/packages/slack/src/__tests__/thread-footer.test.ts b/packages/slack/src/__tests__/thread-footer.test.ts index 5de796575..700308618 100644 --- a/packages/slack/src/__tests__/thread-footer.test.ts +++ b/packages/slack/src/__tests__/thread-footer.test.ts @@ -232,7 +232,7 @@ describe('getSlackThreadFooterText', () => { ); }); - it('omits the live preview link when environment previews are disabled', async () => { + it('includes the live preview link even when the config contains the deprecated previews_enabled: false', async () => { mockEnvironmentBackedTaskRun({ primaryPortName: 'WEB' }); environmentFindFirstMock.mockResolvedValue({ config: { @@ -251,7 +251,7 @@ describe('getSlackThreadFooterText', () => { threadTs: '111.000', }), ).resolves.toBe( - '_Reply or use the ._', + '_Working on a , reply or use the ._', ); }); diff --git a/packages/types/src/__tests__/live-previews.test.ts b/packages/types/src/__tests__/live-previews.test.ts index 600b5b23d..f1afbc9f9 100644 --- a/packages/types/src/__tests__/live-previews.test.ts +++ b/packages/types/src/__tests__/live-previews.test.ts @@ -1,9 +1,7 @@ import { analyzePreviewRuntimeConfig, buildExamplePreviewHostname, - hasAdvancedPreviewConfig, hasConfiguredPreviewPorts, - isEnvironmentPreviewEnabledInConfig, isLocalPreviewDomain, } from '../live-previews'; @@ -78,16 +76,7 @@ describe('live previews helpers', () => { expect( hasConfiguredPreviewPorts({ ports: [{ name: 'WEB', port: 3000 }] }), ).toBe(true); - expect( - isEnvironmentPreviewEnabledInConfig({ previews_enabled: false }), - ).toBe(false); - expect( - hasAdvancedPreviewConfig({ - name: 'WEB', - port: 3000, - proxied: false, - }), - ).toBe(true); + expect(hasConfiguredPreviewPorts({ ports: [] })).toBe(false); expect(buildExamplePreviewHostname('preview.roomote.example.com')).toBe( 'abc123def4567-web.preview.roomote.example.com', ); diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index d0bd87df4..bdd865031 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -691,11 +691,9 @@ export const environmentConfigSchema = z ) .optional(), /** - * Controls whether human-facing live previews should publish for this - * environment without deleting the stored port definitions. - * - * When omitted, legacy behavior is preserved and preview availability - * depends on the deployment-level setting plus configured ports. + * @deprecated Ignored. Live previews are always enabled; availability is + * determined by the preview runtime configuration and `ports`. Kept + * parseable so stored configs that still contain it remain valid. */ previews_enabled: z.boolean().optional(), /** diff --git a/packages/types/src/environment-definition-tasks.ts b/packages/types/src/environment-definition-tasks.ts index 4813d19d7..ed67f7085 100644 --- a/packages/types/src/environment-definition-tasks.ts +++ b/packages/types/src/environment-definition-tasks.ts @@ -88,6 +88,13 @@ Use localhost or the environment's initial URL to confirm the expected service r When you have a clear outcome, record it by calling the ${PRODUCT_NAME} MCP tool \`manage_environments\` with \`action: "record_verification"\`, \`environmentId: "${input.environmentId}"\`, and \`success: true\` when the environment looks ready or \`success: false\` with a short, user-safe \`error\` describing what failed. Do not include secrets or the full environment YAML in the error text.`; } +/** + * Canned change request for the preview pane's "set up previews with an agent" + * CTA. Appended to the update-environment prompt so the environment-setup + * skill focuses on publishing live preview ports. + */ +export const ENVIRONMENT_PREVIEW_SETUP_CHANGE_REQUEST = `Get live previews working for this environment. You are running inside the environment, so its commands and services have already started. Identify the human-facing web UI surface(s), validate that each one serves HTTP on localhost, and add a matching top-level \`ports\` entry for each: short uppercase \`name\`, the validated port, \`initial_path\` when a specific landing path is better than \`/\`, and \`primary: true\` on the main surface. If the config contains \`previews_enabled: false\`, remove it; that flag is deprecated and ignored. Keep every other environment setting unchanged unless it blocks the app from starting.`; + export function appendEnvironmentDefinitionGuidance( prompt: string, guidance: string | null | undefined, diff --git a/packages/types/src/live-previews.ts b/packages/types/src/live-previews.ts index c425eb114..dfe300872 100644 --- a/packages/types/src/live-previews.ts +++ b/packages/types/src/live-previews.ts @@ -1,4 +1,4 @@ -import type { EnvironmentConfig, NamedPort } from './environment-config'; +import type { EnvironmentConfig } from './environment-config'; import { PREVIEW_DOMAIN_ENV_VAR, PREVIEW_PROXY_BASE_URL_ENV_VAR, @@ -236,22 +236,6 @@ export function hasConfiguredPreviewPorts( return Boolean(config?.ports?.length); } -export function isEnvironmentPreviewEnabledInConfig( - config: Pick | undefined, -): boolean { - return config?.previews_enabled !== false; -} - -export function hasAdvancedPreviewConfig(port: NamedPort): boolean { - return Boolean( - port.unauthenticated || - port.proxied === false || - port.subdomain || - port.wildcard_prefix || - (port.auth_bypass_paths && port.auth_bypass_paths.length > 0), - ); -} - export function buildExamplePreviewHostname(domain: string): string { return `abc123def4567-web.${domain}`; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ff79cf8..42b4b61d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,9 +324,6 @@ importers: '@roomote/env': specifier: workspace:^ version: link:../../packages/env - '@roomote/feature-flags': - specifier: workspace:^ - version: link:../../packages/feature-flags '@roomote/redis': specifier: workspace:^ version: link:../../packages/redis