diff --git a/.changeset/environment-verification-state.md b/.changeset/environment-verification-state.md new file mode 100644 index 00000000..93b4125f --- /dev/null +++ b/.changeset/environment-verification-state.md @@ -0,0 +1,5 @@ +--- +"@roomote/web": minor +--- + +Environments now track a persisted verification state that is separate from "a definition exists". A new environment is Configured until a follow-up verification task confirms it works, then it becomes Verified; runtime-affecting edits reset it to Configured while name/description-only edits keep it verified. Onboarding can finish while verification runs, the Environments settings page shows the verification status with a Retry verification action and a link to the related task, and agents record the outcome through the new `manage_environments` `record_verification` action. diff --git a/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts b/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts new file mode 100644 index 00000000..cbaf2625 --- /dev/null +++ b/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts @@ -0,0 +1,208 @@ +import { Hono } from 'hono'; + +import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; + +import type { Variables } from '../../../types'; +import { mcpAuthMiddleware } from '../../mcp/middleware'; +import { + recordVerification, + sanitizeVerificationError, +} from '../recordVerification'; + +const { + mockTaskRunFindFirst, + mockEnvironmentsFindFirst, + mockRecordEnvironmentVerification, +} = vi.hoisted(() => ({ + mockTaskRunFindFirst: vi.fn(), + mockEnvironmentsFindFirst: vi.fn().mockResolvedValue({ id: 'env-1' }), + mockRecordEnvironmentVerification: vi + .fn() + .mockResolvedValue({ recorded: true }), +})); + +vi.mock('@roomote/db/server', async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + recordEnvironmentVerification: mockRecordEnvironmentVerification, + db: { + query: { + taskRuns: { findFirst: mockTaskRunFindFirst }, + environments: { findFirst: mockEnvironmentsFindFirst }, + }, + }, + }; +}); + +function createApp(authContext?: AuthTokenContext | RunTokenContext) { + const app = new Hono<{ Variables: Variables }>(); + + app.use('*', async (c, next) => { + if (authContext) { + c.set('authContext', authContext); + } + await next(); + }); + app.use('*', mcpAuthMiddleware); + app.post('/environments/:id/verification', recordVerification); + + return app; +} + +function runToken(): RunTokenContext { + return { + runId: 42, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + }; +} + +function verificationRequest(id: string, body: unknown): Request { + return new Request(`http://localhost/environments/${id}/verification`, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }); +} + +describe('recordVerification', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEnvironmentsFindFirst.mockResolvedValue({ id: 'env-1' }); + mockRecordEnvironmentVerification.mockResolvedValue({ recorded: true }); + }); + + it('records a successful verification for a task marked to verify the environment', async () => { + mockTaskRunFindFirst.mockResolvedValueOnce({ + taskId: 'task-abc', + payload: { verifiesEnvironmentId: 'env-1' }, + }); + + const app = createApp(runToken()); + const response = await app.request( + verificationRequest('env-1', { success: true }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + success: true, + environmentId: 'env-1', + isVerified: true, + }); + expect(mockRecordEnvironmentVerification).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + environmentId: 'env-1', + verificationTaskId: 'task-abc', + success: true, + }), + ); + }); + + it('authorizes the environment-setup task via the environmentDefinitionId marker', async () => { + mockTaskRunFindFirst.mockResolvedValueOnce({ + taskId: 'task-setup', + payload: { environmentDefinitionId: 'env-1' }, + }); + + const app = createApp(runToken()); + const response = await app.request( + verificationRequest('env-1', { success: false, error: 'boot failed' }), + ); + + expect(response.status).toBe(200); + expect(mockRecordEnvironmentVerification).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + verificationTaskId: 'task-setup', + success: false, + error: 'boot failed', + }), + ); + }); + + it('rejects a task that is not authorized for the target environment', async () => { + mockTaskRunFindFirst.mockResolvedValueOnce({ + taskId: 'task-other', + payload: { verifiesEnvironmentId: 'env-other' }, + }); + + const app = createApp(runToken()); + const response = await app.request( + verificationRequest('env-1', { success: true }), + ); + + expect(response.status).toBe(403); + expect(mockRecordEnvironmentVerification).not.toHaveBeenCalled(); + }); + + it('rejects a user auth token with no run binding', async () => { + const authContext: AuthTokenContext = { + userId: 'user-1', + tokenType: 'auth', + version: 1, + }; + + const app = createApp(authContext); + const response = await app.request( + verificationRequest('env-1', { success: true }), + ); + + expect(response.status).toBe(403); + expect(mockRecordEnvironmentVerification).not.toHaveBeenCalled(); + }); + + it('returns 409 when the verification attempt is superseded', async () => { + mockTaskRunFindFirst.mockResolvedValueOnce({ + taskId: 'task-stale', + payload: { verifiesEnvironmentId: 'env-1' }, + }); + mockRecordEnvironmentVerification.mockResolvedValueOnce({ + recorded: false, + }); + + const app = createApp(runToken()); + const response = await app.request( + verificationRequest('env-1', { success: true }), + ); + + expect(response.status).toBe(409); + }); + + it('requires a boolean success field', async () => { + const app = createApp(runToken()); + const response = await app.request( + verificationRequest('env-1', { error: 'nope' }), + ); + + expect(response.status).toBe(400); + }); +}); + +describe('sanitizeVerificationError', () => { + it('strips secret-looking assignment lines', () => { + const result = sanitizeVerificationError( + 'Startup failed.\nAPI_KEY=super-secret-value\nexport DB_PASSWORD: hunter2', + ); + + expect(result).toBe('Startup failed.'); + }); + + it('strips lowercase and YAML-style secret lines', () => { + const result = sanitizeVerificationError( + 'Could not boot.\ndatabase_url: postgres://user:pw@host/db\n- token: abc123', + ); + + expect(result).toBe('Could not boot.'); + }); + + it('collapses whitespace and returns null for non-strings', () => { + expect(sanitizeVerificationError(' boot failed ')).toBe('boot failed'); + expect(sanitizeVerificationError(undefined)).toBeNull(); + expect(sanitizeVerificationError(123)).toBeNull(); + }); +}); diff --git a/apps/api/src/handlers/environments/createEnvironment.ts b/apps/api/src/handlers/environments/createEnvironment.ts index 897a3898..7d2c33cd 100644 --- a/apps/api/src/handlers/environments/createEnvironment.ts +++ b/apps/api/src/handlers/environments/createEnvironment.ts @@ -172,6 +172,32 @@ export async function attachEnvironmentIdToTaskRun( .where(eq(taskRuns.id, taskRun.id)); } +/** + * Resolve the calling task run's Roomote task id, if the write is driven by a + * run-token task. Used to atomically register that task as the current + * verification attempt for the environment it just created or applied a + * runtime-affecting update to. The environment-setup skill runs verification + * from this same setup task and records the outcome through + * `record_verification`; registering the caller's taskId means that recording + * matches server-side without the agent having to hand-pass any task id. + */ +export async function resolveCallingVerificationTaskId( + auth: McpAuth, +): Promise { + const runId = extractRunId(auth); + + if (!runId) { + return undefined; + } + + const taskRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, runId), + columns: { taskId: true }, + }); + + return taskRun?.taskId ?? undefined; +} + /** * POST /api/mcp/environments * @@ -266,6 +292,8 @@ export async function createEnvironment( return c.json({ error: missingRepositoryError }, 400); } + const verificationTaskId = await resolveCallingVerificationTaskId(auth); + const created = await db.transaction(async (tx) => { const inserted = await tx .insert(environments) @@ -275,6 +303,13 @@ export async function createEnvironment( name: config.name, description: config.description, config, + // New environments start configured but not yet verified. A + // follow-up verification task confirms the runtime works. Registering + // the calling task here means its record_verification matches + // server-side without the agent hand-passing a task id. + isVerified: false, + verificationTaskId: verificationTaskId ?? null, + verificationError: null, }) .returning({ id: environments.id }); diff --git a/apps/api/src/handlers/environments/index.ts b/apps/api/src/handlers/environments/index.ts index 455495ed..92ec4d17 100644 --- a/apps/api/src/handlers/environments/index.ts +++ b/apps/api/src/handlers/environments/index.ts @@ -5,10 +5,12 @@ import { createEnvironment } from './createEnvironment'; import { getEnvironment } from './getEnvironment'; import { updateEnvironment } from './updateEnvironment'; import { listEnvironments } from './listEnvironments'; +import { recordVerification } from './recordVerification'; export const environmentsRouter = new Hono<{ Variables: Variables }>(); environmentsRouter.post('/', createEnvironment); environmentsRouter.patch('/:id', updateEnvironment); +environmentsRouter.post('/:id/verification', recordVerification); environmentsRouter.get('/:id', getEnvironment); environmentsRouter.get('/', listEnvironments); diff --git a/apps/api/src/handlers/environments/recordVerification.ts b/apps/api/src/handlers/environments/recordVerification.ts new file mode 100644 index 00000000..dfa624b3 --- /dev/null +++ b/apps/api/src/handlers/environments/recordVerification.ts @@ -0,0 +1,221 @@ +import type { Context } from 'hono'; + +import { + db, + environments, + eq, + recordEnvironmentVerification, + taskRuns, +} from '@roomote/db/server'; +import { getEnvironmentDefinitionIdFromPayload } from '@roomote/types'; + +import type { Variables } from '../../types'; +import type { McpAuth } from '../mcp/middleware'; +import { logHandlerError } from '../utils'; + +const MAX_VERIFICATION_ERROR_LENGTH = 2_000; + +/** + * Reduce an agent-provided failure message to a compact, user-safe string. + * + * Never persist secrets or full environment YAML. This drops lines that look + * like key/value assignments or YAML mappings (which could carry credentials), + * collapses whitespace, and truncates to a bounded length. + */ +export function sanitizeVerificationError(raw: unknown): string | null { + if (typeof raw !== 'string') { + return null; + } + + const withoutSecrets = raw + .split(/\r?\n/) + .filter((line) => { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + // Drop lines that look like environment-variable assignments, secret + // key/value pairs, or YAML mappings (`key: value` / `- key: value`) to + // avoid persisting credentials or dumping full environment YAML. Matched + // case-insensitively so lowercase keys are covered too. + return !/^(?:-\s*)?(?:export\s+)?[A-Za-z0-9_.-]{2,}\s*[:=]\s*\S/.test( + trimmed, + ); + }) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!withoutSecrets) { + return null; + } + + return withoutSecrets.length > MAX_VERIFICATION_ERROR_LENGTH + ? `${withoutSecrets.slice(0, MAX_VERIFICATION_ERROR_LENGTH - 1)}…` + : withoutSecrets; +} + +function extractRunId(auth: McpAuth): number | null { + return 'runId' in auth.authContext ? auth.authContext.runId : null; +} + +/** + * Resolve the calling verification task from its run token. + * + * Only run-token tasks carrying a verification marker in their payload may + * record verification, and only for that exact environment. Returns the + * calling task's own Roomote taskId (the identity used to match the current + * verification attempt) alongside the authorized environment id, or null when + * the caller is not an authorized verification flow for the target + * environment. + */ +async function resolveVerificationCaller( + auth: McpAuth, + targetEnvironmentId: string, +): Promise<{ taskId: string } | null> { + const runId = extractRunId(auth); + + if (!runId) { + return null; + } + + const taskRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, runId), + columns: { taskId: true, payload: true }, + }); + + if (!taskRun?.taskId) { + return null; + } + + if (!taskRun.payload || typeof taskRun.payload !== 'object') { + return null; + } + + const payload = taskRun.payload as Record; + const marker = payload.verifiesEnvironmentId; + + const authorizedEnvironmentId = + typeof marker === 'string' && marker.trim().length > 0 + ? marker.trim() + : // Fall back to the environment-definition marker so the + // environment-setup skill's own setup task (which records the result + // after monitoring its spawned verification sub-task) is authorized + // for the environment it just persisted. + getEnvironmentDefinitionIdFromPayload(payload); + + if ( + !authorizedEnvironmentId || + authorizedEnvironmentId !== targetEnvironmentId + ) { + return null; + } + + return { taskId: taskRun.taskId }; +} + +/** + * POST /api/mcp/environments/:id/verification + * + * Records the terminal result of an environment verification task. Only the + * active verification flow for this environment may call it. The recording + * task's identity is derived server-side from the run token, so a task cannot + * record a result on behalf of another verification attempt. + */ +export async function recordVerification( + c: Context<{ Variables: Variables & { mcpAuth: McpAuth } }>, +): Promise { + const auth = c.get('mcpAuth'); + const id = c.req.param('id'); + + if (!id) { + return c.json({ error: 'Environment id is required' }, 400); + } + + let body: unknown; + + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + + if (!body || typeof body !== 'object') { + return c.json({ error: 'Request body is required' }, 400); + } + + const requestBody = body as { + success?: unknown; + error?: unknown; + }; + + if (typeof requestBody.success !== 'boolean') { + return c.json({ error: 'success must be a boolean' }, 400); + } + + const caller = await resolveVerificationCaller(auth, id); + + if (!caller) { + return c.json( + { + error: + 'This task is not authorized to record verification for this environment.', + }, + 403, + ); + } + + try { + const environment = await db.query.environments.findFirst({ + where: eq(environments.id, id), + columns: { id: true }, + }); + + if (!environment) { + return c.json({ error: 'Environment not found' }, 404); + } + + const sanitizedError = requestBody.success + ? null + : sanitizeVerificationError(requestBody.error); + + const { recorded } = await recordEnvironmentVerification(db, { + environmentId: id, + // Match against the caller's own task id, derived server-side. + verificationTaskId: caller.taskId, + success: requestBody.success, + error: sanitizedError, + }); + + if (!recorded) { + // The environment's current verification task no longer matches this + // caller (a newer verification, retry, or runtime-affecting edit + // superseded it). Reject rather than clobber the newer state. + return c.json( + { + error: + 'Verification result rejected: it does not match the current verification attempt for this environment.', + }, + 409, + ); + } + + return c.json({ + success: true, + environmentId: id, + isVerified: requestBody.success, + }); + } catch (error) { + logHandlerError('recordVerification', error); + return c.json( + { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to record verification', + }, + 500, + ); + } +} diff --git a/apps/api/src/handlers/environments/updateEnvironment.ts b/apps/api/src/handlers/environments/updateEnvironment.ts index 36b48291..1b6e5a4e 100644 --- a/apps/api/src/handlers/environments/updateEnvironment.ts +++ b/apps/api/src/handlers/environments/updateEnvironment.ts @@ -23,6 +23,7 @@ import { attachEnvironmentIdToTaskRun, getEnvironmentRepositoryConfigError, isEnvironmentNameUniqueViolation, + resolveCallingVerificationTaskId, resolveEnvironmentWriteUserId, } from './createEnvironment'; @@ -155,6 +156,8 @@ export async function updateEnvironment( .map((name) => repoMap.get(name)) .filter((repositoryId): repositoryId is string => Boolean(repositoryId)); + const verificationTaskId = await resolveCallingVerificationTaskId(auth); + await db.transaction(async (tx) => { const now = new Date(); @@ -167,6 +170,11 @@ export async function updateEnvironment( }, updatedAt: now, repositoryIds, + // When this runtime-affecting update resets verification, register the + // calling setup task as the current attempt atomically under the same + // row lock, so a concurrent edit cannot associate an older task with + // this newer configuration. + registerVerificationTaskId: verificationTaskId, configVersion: { config: environment.config, name: environment.name, diff --git a/apps/preview-proxy/src/lib/logger.ts b/apps/preview-proxy/src/lib/logger.ts index d4b87b4b..c092c7fc 100644 --- a/apps/preview-proxy/src/lib/logger.ts +++ b/apps/preview-proxy/src/lib/logger.ts @@ -1,25 +1,28 @@ import pino from 'pino'; +import pinoPretty from 'pino-pretty'; import { config } from '../config'; import { getRequestContext } from './request-context'; -export const logger = pino({ +const loggerOptions: pino.LoggerOptions = { level: config.LOG_LEVEL || (config.NODE_ENV === 'production' ? 'info' : 'debug'), - transport: - config.NODE_ENV === 'development' - ? { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'HH:MM:ss', - ignore: 'pid,hostname', - }, - } - : undefined, mixin() { return getRequestContext() || {}; }, -}); +}; + +const developmentPrettyStream = + config.NODE_ENV === 'development' + ? pinoPretty({ + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + }) + : undefined; + +export const logger = developmentPrettyStream + ? pino(loggerOptions, developmentPrettyStream) + : pino(loggerOptions); export function escapeForLog(input: string): string { return ( diff --git a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.client.test.tsx index 1fbfffcd..4fdc5bc6 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.client.test.tsx @@ -9,19 +9,6 @@ vi.mock( () => ({ useEnvironmentDefinitionAgentState: (...args: unknown[]) => useEnvironmentDefinitionAgentStateMock(...args), - EnvironmentDefinitionAgentTaskPanel: ({ - title, - showHeader = true, - }: { - title?: string; - session: unknown; - className?: string; - showHeader?: boolean; - }) => ( -
- {showHeader ? (title ?? 'environment definition panel') : 'panel'} -
- ), }), ); @@ -31,25 +18,48 @@ vi.mock('sonner', () => ({ }, })); +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock('@/hooks/environments', () => ({ + useRetryEnvironmentVerification: () => ({ + isPending: false, + variables: undefined, + mutate: vi.fn(), + }), +})); + vi.mock('@/components/sandbox', () => ({ TaskStatusIndicator: () =>
task status
, })); vi.mock('@/components/system', () => ({ + Check: (props: SVGProps) => , GripVertical: (props: SVGProps) => , Maximize2: (props: SVGProps) => , Button: ({ children, onClick, + asChild, ...props }: { children: ReactNode; onClick?: () => void; - } & HTMLAttributes) => ( - - ), + asChild?: boolean; + } & HTMLAttributes) => { + if (asChild) { + return <>{children}; + } + + return ( + + ); + }, })); import { SetupOnboardingAgentWidget } from './SetupOnboardingAgentWidget'; @@ -133,7 +143,7 @@ describe('SetupOnboardingAgentWidget', () => { expect(onOpenStep).toHaveBeenCalledTimes(1); }); - it('renders the transcript inline in the floating window when expanded without a duplicate inner header', () => { + it('links to the normal task page when expanded', () => { render( { screen.getByRole('button', { name: 'Collapse onboarding agent' }), ).toBeInTheDocument(); expect(screen.getAllByText('Onboarding agent')).toHaveLength(1); - expect(screen.queryByText('task status')).not.toBeInTheDocument(); + expect(screen.getByText('task status')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View task' })).toHaveAttribute( + 'href', + '/task/task-1', + ); }); it('shows Finish in expanded mode when onboarding succeeded and closes with toast', () => { diff --git a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx index 992966b2..64387692 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx @@ -7,14 +7,14 @@ import { useRef, type PointerEvent as ReactPointerEvent, } from 'react'; +import Link from 'next/link'; import { toast } from 'sonner'; +import { SETTINGS_PATHS } from '@/lib/settings'; +import { useRetryEnvironmentVerification } from '@/hooks/environments'; import { Button, Check, GripVertical, Maximize2 } from '@/components/system'; import { TaskStatusIndicator } from '@/components/sandbox'; -import { - EnvironmentDefinitionAgentTaskPanel, - useEnvironmentDefinitionAgentState, -} from '@/components/settings/environments/EnvironmentDefinitionAgentTask'; +import { useEnvironmentDefinitionAgentState } from '@/components/settings/environments/EnvironmentDefinitionAgentTask'; type WidgetPosition = { x: number; @@ -73,11 +73,18 @@ export function SetupOnboardingAgentWidget({ onOpenStep: () => void; onFinish: () => void; }) { - const { session, succeeded, failed, matchingEnvironment } = - useEnvironmentDefinitionAgentState({ - taskId, - mode: 'create', - }); + const { + session, + succeeded, + failed, + matchingEnvironment, + verificationPending, + verificationSucceeded, + verificationFailed, + } = useEnvironmentDefinitionAgentState({ + taskId, + mode: 'create', + }); const widgetRef = useRef(null); const dragStateRef = useRef(null); const positionRef = useRef(position); @@ -85,9 +92,27 @@ export function SetupOnboardingAgentWidget({ positionRef.current = position; onPositionChangeRef.current = onPositionChange; + const retryVerification = useRetryEnvironmentVerification(); + const environmentId = matchingEnvironment?.id ?? null; + const verificationTaskId = matchingEnvironment?.verificationTaskId ?? null; + const showVerificationActions = + !!matchingEnvironment && (verificationPending || verificationFailed); + const statusCopy = useMemo(() => { + if (verificationSucceeded) { + return 'Your environment is verified and ready to use.'; + } + + if (verificationFailed) { + return 'Your environment is configured, but Roomote could not verify that it works.'; + } + + if (verificationPending) { + return 'Your environment is configured. Roomote is checking that it works; you can continue while this finishes.'; + } + if (succeeded) { - return 'Your first environment is ready. Finish setup when you are ready to continue.'; + return 'Your first environment is configured. Finish setup when you are ready to continue.'; } if (failed) { @@ -95,7 +120,13 @@ export function SetupOnboardingAgentWidget({ } return 'Understanding your codebase and getting your environment ready.'; - }, [failed, succeeded]); + }, [ + failed, + succeeded, + verificationFailed, + verificationPending, + verificationSucceeded, + ]); const commitClampedPosition = useCallback(() => { const current = positionRef.current; @@ -216,19 +247,29 @@ export function SetupOnboardingAgentWidget({ {expanded ? (
- - {succeeded ? ( -
+
+ +

{statusCopy}

+
+
+ + {succeeded ? ( -
- ) : null} + ) : null} +
) : (
@@ -242,7 +283,7 @@ export function SetupOnboardingAgentWidget({ />

{statusCopy}

-
+
{succeeded ? ( ) : null} + {showVerificationActions ? ( + <> + {verificationFailed && environmentId ? ( + + ) : null} + {environmentId ? ( + + ) : null} + {verificationTaskId ? ( + + ) : null} + + ) : null}
)} diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx index 28df1ac3..fcaf4971 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx @@ -294,7 +294,7 @@ describe('Setup StepInvoke', () => { expect( screen.getByText( - /once your environment is ready, you can work with roomote in these ways/i, + /once your environment is configured, you can work with roomote in these ways/i, ), ).toBeInTheDocument(); fireEvent.click( diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx index 353c437c..e6ed3db7 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx @@ -149,7 +149,7 @@ export function StepInvoke({

{onboardingTaskId - ? `Once your environment is ready, you can work with ${PRODUCT_NAME} in these ways:` + ? `Once your environment is configured, you can work with ${PRODUCT_NAME} in these ways (verification may still be in progress):` : `How to work with ${PRODUCT_NAME}:`}

{computeProvisioning?.status === 'building' ? ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.client.test.tsx index ef2aba5d..7775bcdd 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.client.test.tsx @@ -1,5 +1,5 @@ +import type { ButtonHTMLAttributes } from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; const { invalidateQueriesMock, @@ -25,19 +25,11 @@ vi.mock('@/trpc/client', () => ({ vi.mock('@/components/system', () => ({ AlertCircle: () => , - RefreshCw: () => , - Button: ({ - children, - ...props - }: { - children: ReactNode; - disabled?: boolean; - onClick?: () => void; - }) => ( - + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + ), + RefreshCw: () => , + Spinner: () => , })); vi.mock('./hooks', () => ({ @@ -142,7 +134,7 @@ describe('ConnectionStatusBanner', () => { ); expect( - screen.queryByText('Connecting to the live task...'), + screen.queryByText('Trying to connect to the sandbox...'), ).not.toBeInTheDocument(); expect(container).toBeEmptyDOMElement(); }); @@ -169,7 +161,7 @@ describe('ConnectionStatusBanner', () => { ); expect( - screen.getByText('Reconnecting to the live task...'), + screen.getByText('Trying to connect to the sandbox...'), ).toBeInTheDocument(); expect( screen.queryByText('Lost connection to the live task'), @@ -201,7 +193,7 @@ describe('ConnectionStatusBanner', () => { ); expect( - screen.getByText('Connecting to the live task...'), + screen.getByText('Trying to connect to the sandbox...'), ).toBeInTheDocument(); expect( screen.queryByText('Reconnecting to the live task...'), @@ -211,14 +203,17 @@ describe('ConnectionStatusBanner', () => { ).not.toBeInTheDocument(); }); - it('shows a backend-unavailable message when the initial connection retry budget is exhausted', () => { + it('refreshes and reconnects when initial retries are exhausted', async () => { + const connectionTarget = { url: 'https://sandbox.test', token: 'token' }; + const reconnect = vi.fn(); + const refreshConnection = vi.fn().mockResolvedValue(connectionTarget); useSandboxConnectionStatusMock.mockReturnValue({ connected: false, hasConnectedOnce: false, connectionError: true, connectionFailureCategory: 'backend_unavailable', reconnecting: false, - reconnect: vi.fn(), + reconnect, }); render( @@ -227,7 +222,7 @@ describe('ConnectionStatusBanner', () => { { taskId: 'task-123', hasTransportError: false, - refreshConnection: vi.fn(), + refreshConnection, } as never } />, @@ -236,14 +231,49 @@ describe('ConnectionStatusBanner', () => { expect( screen.getByText('Could not reach the live task'), ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Try again' }), - ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(refreshConnection).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(reconnect).toHaveBeenCalledWith(connectionTarget); + }); expect( screen.queryByText('Lost connection to the live task'), ).not.toBeInTheDocument(); }); + it('resyncs the session when manual retry refresh fails', async () => { + const reconnect = vi.fn(); + const refreshConnection = vi + .fn() + .mockRejectedValue(new Error('token unavailable')); + useSandboxConnectionStatusMock.mockReturnValue({ + connected: false, + hasConnectedOnce: false, + connectionError: true, + connectionFailureCategory: 'backend_unavailable', + reconnecting: false, + reconnect, + }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + await waitFor(() => { + expect(reconnect).toHaveBeenCalledWith(null); + }); + }); + it('suppresses the lost-connection error while the sleep snapshot is in progress', () => { useSandboxConnectionStatusMock.mockReturnValue({ connected: false, @@ -403,45 +433,6 @@ describe('ConnectionStatusBanner', () => { ).toBeInTheDocument(); }); - it('passes the refreshed sandbox connection target into reconnect', async () => { - const reconnectMock = vi.fn(); - const refreshConnectionMock = vi.fn().mockResolvedValue({ - url: 'http://sandbox-refreshed.test', - token: 'token-refreshed', - }); - - useSandboxConnectionStatusMock.mockReturnValue({ - connected: false, - hasConnectedOnce: true, - connectionError: true, - connectionFailureCategory: null, - reconnecting: false, - reconnect: reconnectMock, - }); - - render( - , - ); - - fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })); - - await waitFor(() => { - expect(refreshConnectionMock).toHaveBeenCalledTimes(1); - expect(reconnectMock).toHaveBeenCalledWith({ - url: 'http://sandbox-refreshed.test', - token: 'token-refreshed', - }); - }); - }); - it('shows an auth-specific message when reconnect auth refresh fails', () => { useSandboxConnectionStatusMock.mockReturnValue({ connected: false, @@ -498,7 +489,20 @@ describe('ConnectionStatusBanner', () => { ).toBeInTheDocument(); }); - it('shows an auth-specific initial message for token/bootstrap failures', () => { + it('refreshes and reconnects for token/bootstrap failures', async () => { + const connectionTarget = { url: 'https://sandbox.test', token: 'token' }; + const reconnect = vi.fn(); + const refreshConnection = vi.fn().mockResolvedValue(connectionTarget); + + useSandboxConnectionStatusMock.mockReturnValue({ + connected: false, + hasConnectedOnce: false, + connectionError: false, + connectionFailureCategory: null, + reconnecting: false, + reconnect, + }); + render( { taskId: 'task-123', hasTransportError: true, transportErrorCategory: 'auth_error', - refreshConnection: vi.fn(), + refreshConnection, } as never } />, @@ -515,8 +519,10 @@ describe('ConnectionStatusBanner', () => { expect( screen.getByText('Could not verify access to the live task'), ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Try again' }), - ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(refreshConnection).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(reconnect).toHaveBeenCalledWith(connectionTarget); + }); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx index 0e225115..61462b62 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx @@ -1,37 +1,14 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { AlertCircle, RefreshCw, Button } from '@/components/system'; +import { AlertCircle, Button, RefreshCw, Spinner } from '@/components/system'; import { useTRPC } from '@/trpc/client'; import { type TaskSession, useSandboxConnectionStatus } from './hooks'; import { isTaskRunSnapshotting } from './sidebar-actions/utils'; -function getInitialConnectionMessage({ - showReconnectState, - connectionFailureCategory, -}: { - showReconnectState: boolean; - connectionFailureCategory: TaskSession['transportErrorCategory']; -}) { - if (showReconnectState) { - return 'Connecting to the live task...'; - } - - switch (connectionFailureCategory) { - case 'auth_error': - return 'Could not verify access to the live task'; - case 'backend_unavailable': - return 'Could not reach the live task'; - case 'transport_error': - return 'Could not connect to the live task'; - default: - return 'Live task is taking longer than usual to connect'; - } -} - function getErrorMessage({ hasConnectedOnce, connectionError, @@ -65,20 +42,18 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const [retrying, setRetrying] = useState(false); - const { connected, hasConnectedOnce, connectionError, connectionFailureCategory, - reconnecting, reconnect, + reconnecting, } = useSandboxConnectionStatus(); const effectiveFailureCategory = connectionFailureCategory ?? session.transportErrorCategory; - const showReconnectState = reconnecting || retrying; + const showReconnectState = reconnecting; const showError = !showReconnectState && (connectionError || session.hasTransportError); const showInitialConnectionState = @@ -99,18 +74,6 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { }); }, [queryClient, session.taskId, shouldInvalidateSandboxSession, trpc]); - const handleRetry = useCallback(async () => { - setRetrying(true); - - try { - const refreshedConnection = await session.refreshConnection(); - reconnect(refreshedConnection); - } finally { - // Give the connection attempt a moment before allowing another retry. - setTimeout(() => setRetrying(false), 2_000); - } - }, [session, reconnect]); - if (connected) { return null; } @@ -128,41 +91,52 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { return null; } - if (showInitialConnectionState) { + if (showInitialConnectionState && showError) { return ( -
- - - {getInitialConnectionMessage({ - showReconnectState, - connectionFailureCategory: effectiveFailureCategory, - })} - - {!showReconnectState ? ( +
+
+ + + {getErrorMessage({ + hasConnectedOnce, + connectionError, + connectionFailureCategory: effectiveFailureCategory, + })} + - ) : null} +
); } - if (showReconnectState) { + if (showInitialConnectionState || showReconnectState) { return ( -
- - - Reconnecting to the live task... - +
+
+ + Trying to connect to the sandbox... +
); } @@ -172,27 +146,17 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { } return ( -
- - - {getErrorMessage({ - hasConnectedOnce, - connectionError, - connectionFailureCategory: effectiveFailureCategory, - })} - - +
+
+ + + {getErrorMessage({ + hasConnectedOnce, + connectionError, + connectionFailureCategory: effectiveFailureCategory, + })} + +
); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx index 57f3e48c..19dafc70 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx @@ -6,12 +6,18 @@ const { isTaskRunAsleepMock } = vi.hoisted(() => ({ })); vi.mock('@/components/system', () => ({ + ArrowUpRightIcon: () => , ArrowRight: () => , + Badge: ({ children }: { children: ReactNode }) => {children}, + BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}, Button: ({ children }: { children: ReactNode }) => ( ), Check: () => , + HelpCircle: () => , + Loader2: () => , Sun: () => , + X: () => , })); vi.mock('@/components/ai-elements', () => ({ @@ -217,14 +223,21 @@ describe('HistoricalContent', () => { }, taskId: 'task-123', artifacts: [], - onboardingEnvironment: { name: 'Satanama' }, + onboardingEnvironment: { + name: 'Satanama', + isVerified: true, + verificationTaskId: 'verify-123', + verificationTaskActive: false, + verifiedAt: new Date('2026-05-22T21:00:00.000Z'), + verificationError: null, + }, } as never } />, ); expect( - screen.getByText('The Satanama environment is set up.'), + screen.getByText('The Satanama environment is set up and verified.'), ).toBeInTheDocument(); expect( screen.getByText('You can start your first task now.'), @@ -234,4 +247,45 @@ describe('HistoricalContent', () => { '/', ); }); + + it('shows an unverified onboarding environment as set up but not verified', () => { + render( + , + ); + + expect( + screen.getByText( + 'The Satanama environment is set up, but not verified yet.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + "You can start a task, but it's worth checking verification before relying on it.", + ), + ).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx index 342a9d9c..9726cfd6 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx @@ -36,7 +36,7 @@ export function HistoricalContent({ session, footer }: HistoricalContentProps) { const draftPrompt = session.draftPrompt; const isAsleep = isTaskRunAsleep(session.taskRun); const taskRun = session.taskRun; - const onboardingEnvironmentName = session.onboardingEnvironment?.name; + const onboardingEnvironment = session.onboardingEnvironment; const shouldShowWakeTaskInput = isAsleep && Boolean(taskRun?.snapshotId); const [messagesInitialScrollBehavior, setMessagesInitialScrollBehavior] = useState<'smooth' | 'instant'>('smooth'); @@ -55,10 +55,8 @@ export function HistoricalContent({ session, footer }: HistoricalContentProps) { return ; }, [taskRun]); const messagesFooter = useMemo(() => { - const onboardingCompletionFooter = onboardingEnvironmentName ? ( - + const onboardingCompletionFooter = onboardingEnvironment ? ( + ) : null; return ( @@ -67,7 +65,7 @@ export function HistoricalContent({ session, footer }: HistoricalContentProps) { {footer ?? taskFailureFooter ?? onboardingCompletionFooter} ); - }, [footer, isResuming, onboardingEnvironmentName, taskFailureFooter]); + }, [footer, isResuming, onboardingEnvironment, taskFailureFooter]); useEffect(() => { setMessagesInitialScrollBehavior('instant'); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx index 55870456..6c3b85e9 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx @@ -253,7 +253,7 @@ function LiveContentInner({ footer={ session.onboardingEnvironment ? ( ) : undefined } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/OnboardingCompletionMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/OnboardingCompletionMessage.tsx index b6ccf041..97630a40 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/OnboardingCompletionMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/OnboardingCompletionMessage.tsx @@ -1,32 +1,85 @@ import Link from 'next/link'; -import { ArrowRight, Button, Check } from '@/components/system'; +import { ArrowRight, Button } from '@/components/system'; import { Message, MessageContent } from '@/components/ai-elements'; +import { + environmentVerificationDisplay, + getEnvironmentVerificationState, + type EnvironmentVerificationState, +} from '@/components/settings/environments/EnvironmentVerificationStatus'; export function OnboardingCompletionMessage({ - environmentName, + environment, }: { - environmentName: string; + environment: { + name: string; + isVerified: boolean; + verificationTaskId: string | null; + verificationTaskActive: boolean; + verificationError: string | null; + }; }) { + const state = getEnvironmentVerificationState(environment); + const { Icon, iconClassName } = environmentVerificationDisplay[state]; + const copy = getOnboardingCompletionCopy(state, environment.name); + return (
- +
-

- The {environmentName} environment is set up. -

-

You can start your first task now.

- +

{copy.title}

+

{copy.body}

+
+ + {(state === 'failed' || state === 'configured') && ( + + )} +
); } + +function getOnboardingCompletionCopy( + state: EnvironmentVerificationState, + environmentName: string, +) { + switch (state) { + case 'verified': + return { + title: `The ${environmentName} environment is set up and verified.`, + body: 'You can start your first task now.', + }; + case 'in_progress': + return { + title: `The ${environmentName} environment is set up.`, + body: 'Roomote is still verifying it. You can start your first task now.', + }; + case 'failed': + return { + title: `The ${environmentName} environment is set up, but verification failed.`, + body: "You can start a task, but it's worth checking why verification failed.", + }; + case 'configured': + default: + return { + title: `The ${environmentName} environment is set up, but not verified yet.`, + body: "You can start a task, but it's worth checking verification before relying on it.", + }; + } +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.client.test.tsx index 2299cd43..28e98153 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.client.test.tsx @@ -7,6 +7,7 @@ const { appendAcpEventMock, reloadDeploymentEnvVarsMock, sendPromptMock, + markFulfilledMock, successToastMock, errorToastMock, authState, @@ -17,6 +18,7 @@ const { appendAcpEventMock: vi.fn(), reloadDeploymentEnvVarsMock: vi.fn(), sendPromptMock: vi.fn(), + markFulfilledMock: vi.fn(), successToastMock: vi.fn(), errorToastMock: vi.fn(), authState: { @@ -78,6 +80,11 @@ vi.mock('@/trpc/client', () => ({ mutate: sendPromptMock, }, }, + taskEnvVarRequests: { + markFulfilled: { + mutate: markFulfilledMock, + }, + }, }), })); @@ -427,7 +434,10 @@ describe('PendingEnvVarRequestPanel', () => { expect(sendPromptMock).not.toHaveBeenCalled(); }); - it('keeps the request visible when the safe follow-up prompt fails', async () => { + it('dismisses the request when the safe follow-up prompt fails after reload', async () => { + const consoleWarn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); sendPromptMock.mockRejectedValue(new Error('send failed')); renderPanel(); @@ -438,18 +448,39 @@ describe('PendingEnvVarRequestPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { - expect(errorToastMock).toHaveBeenCalledWith('send failed'); + expect(successToastMock).toHaveBeenCalledWith( + 'Environment variables saved. The task is reconnecting, so ask the agent to continue once it is connected.', + ); }); expect(reloadDeploymentEnvVarsMock).toHaveBeenCalledWith(); expect( - screen.getByRole('button', { + screen.queryByRole('button', { name: 'Dismiss environment variable request', }), - ).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Value for OPENAI_API_KEY')).toHaveValue( - 'new-openai-key', + ).not.toBeInTheDocument(); + expect(errorToastMock).not.toHaveBeenCalled(); + expect(consoleWarn).toHaveBeenCalledWith( + 'Saved environment variables, but failed to send the task follow-up prompt.', + expect.any(Error), ); - expect(appendAcpEventMock).not.toHaveBeenCalled(); + expect(appendAcpEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'roomote_runtime.user_prompt', + payload: expect.objectContaining({ + clientMessageId: expect.stringMatching(/^env-var-request-fulfilled:/), + }), + visibleInTranscript: false, + }), + ); + // A durable fulfillment must be persisted server-side on the send-failure + // path so a reconnect does not resurface the already-handled request. + expect(markFulfilledMock).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-1', + clientMessageId: expect.stringMatching(/^env-var-request-fulfilled:/), + }), + ); + consoleWarn.mockRestore(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx index c8e29acd..7c292702 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx @@ -37,6 +37,7 @@ import { interface PendingEnvVarRequestPanelProps { taskId: string; + onVisibleRequestKeyChange?: (requestKey: string | null) => void; } const SAFE_FOLLOW_UP_PROMPT = @@ -99,6 +100,7 @@ function createHiddenEnvVarFulfillmentMessage({ export function PendingEnvVarRequestPanel({ taskId, + onVisibleRequestKeyChange, }: PendingEnvVarRequestPanelProps) { const { isAdmin } = useAuthorizedUser(); const client = useSandboxClient(); @@ -121,6 +123,10 @@ export function PendingEnvVarRequestPanel({ const visibleRequest = request && request.key !== dismissedRequestKey ? request : null; + useEffect(() => { + onVisibleRequestKeyChange?.(visibleRequest?.key ?? null); + }, [onVisibleRequestKeyChange, visibleRequest?.key]); + useEffect(() => { if (!visibleRequest) { setValues({}); @@ -210,16 +216,50 @@ export function PendingEnvVarRequestPanel({ } await client.commands.reloadDeploymentEnvVars.mutate(); - await trpcClient.sandboxSession.sendPrompt.mutate({ - taskId, - prompt: SAFE_FOLLOW_UP_PROMPT, - source: 'web', - clientMessageId, - }); + let sentFollowUpPrompt = true; + + try { + await trpcClient.sandboxSession.sendPrompt.mutate({ + taskId, + prompt: SAFE_FOLLOW_UP_PROMPT, + source: 'web', + clientMessageId, + }); + } catch (error) { + sentFollowUpPrompt = false; + console.warn( + 'Saved environment variables, but failed to send the task follow-up prompt.', + error, + ); + + // The follow-up prompt (which would have persisted the durable + // fulfillment envelope) failed, so record that envelope directly. + // Without this, a reconnect or history refetch rebuilds the pending + // request from persisted envelopes and resurfaces it even though the + // values were already saved. + try { + await trpcClient.taskEnvVarRequests.markFulfilled.mutate({ + taskId, + clientMessageId, + }); + } catch (markError) { + console.warn( + 'Failed to persist the env-var fulfillment marker after the follow-up prompt failed.', + markError, + ); + } + } + recordFulfillmentLocally(clientMessageId); setValues({}); setRevealedVariables({}); setDismissedRequestKey(visibleRequest.key); + + if (!sentFollowUpPrompt) { + toast.success( + 'Environment variables saved. The task is reconnecting, so ask the agent to continue once it is connected.', + ); + } } catch (error) { toast.error( error instanceof Error @@ -230,7 +270,7 @@ export function PendingEnvVarRequestPanel({ }; return ( -
+
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx index a7f36c18..f9275bfe 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useEffect, useState } from 'react'; + import type { TaskSession } from './hooks/use-task-session'; import { PendingUserInputRequestPanel, @@ -28,15 +30,28 @@ export function TaskInputStack({ scrollToBottom: () => void; }) { const { shouldHidePromptInput } = usePendingUserInputRequestState(); + const [visibleEnvVarRequestKey, setVisibleEnvVarRequestKey] = useState< + string | null + >(null); const bootingTaskRun = session.sessionState === 'booting' ? session.taskRun : null; + useEffect(() => { + setVisibleEnvVarRequestKey(null); + }, [session.taskId]); + return ( <> - + - + {bootingTaskRun ? (
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx index 5bb17ba8..5d0ab3ea 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx @@ -35,20 +35,26 @@ const TODO_INACTIVE_TASK_PHASES: ReadonlySet = new Set([ * before the provider is available. */ interface TodoListProps { + autoCollapseKey?: string | null; taskEntryKey?: string; } -export const TodoList = ({ taskEntryKey }: TodoListProps) => { +export const TodoList = ({ autoCollapseKey, taskEntryKey }: TodoListProps) => { const isInsideProvider = useIsInsideSandboxProvider(); if (!isInsideProvider) { return null; } - return ; + return ( + + ); }; -const TodoListContent = ({ taskEntryKey }: TodoListProps) => { +const TodoListContent = ({ autoCollapseKey, taskEntryKey }: TodoListProps) => { const todos = useSandboxTodos(); const taskPhase = useSandboxTaskPhase(); const shouldSuppressActiveStatus = @@ -63,6 +69,7 @@ const TodoListContent = ({ taskEntryKey }: TodoListProps) => { const allDone = todos.length > 0 && completedCount === todos.length; const [isHiddenAfterCompletion, setIsHiddenAfterCompletion] = useState(false); const [isOpen, setIsOpen] = useState(() => !allDone); + const autoCollapsedKeysRef = useRef(new Set()); const wasAllDoneRef = useRef(allDone); useLayoutEffect(() => { @@ -70,6 +77,8 @@ const TodoListContent = ({ taskEntryKey }: TodoListProps) => { return; } + autoCollapsedKeysRef.current.clear(); + const mobileQuery = window.matchMedia?.('(max-width: 767px)'); if (!mobileQuery?.matches) { @@ -80,6 +89,19 @@ const TodoListContent = ({ taskEntryKey }: TodoListProps) => { setIsOpen(false); }, [taskEntryKey]); + useEffect(() => { + if (!autoCollapseKey) { + return; + } + + if (autoCollapsedKeysRef.current.has(autoCollapseKey)) { + return; + } + + autoCollapsedKeysRef.current.add(autoCollapseKey); + setIsOpen(false); + }, [autoCollapseKey]); + useEffect(() => { if (!allDone) { setIsHiddenAfterCompletion(false); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/task-notification-phase.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/task-notification-phase.ts index 8a542a82..f0287869 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/task-notification-phase.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/task-notification-phase.ts @@ -6,7 +6,8 @@ type TaskNotificationSessionState = | 'resuming' | 'boot-failed' | 'historical' - | 'not-found'; + | 'not-found' + | 'error'; export function getTaskNotificationPhase({ sessionState, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts index b69c4cb8..a196b397 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts @@ -30,7 +30,7 @@ export type TaskArtifact = RouterOutput['artifacts']['forTask'][number]; type QuerySessionState = RouterOutput['sandboxSession']['byTaskId']['sessionState']; -export type SessionState = QuerySessionState | 'not-found'; +export type SessionState = QuerySessionState | 'not-found' | 'error'; const EMPTY_ARTIFACTS: TaskArtifact[] = []; @@ -77,7 +77,14 @@ export interface TaskSession { artifacts: TaskArtifact[]; /** Environment created by a completed setup-onboarding task, when available. */ - onboardingEnvironment?: { name: string } | null; + onboardingEnvironment?: { + name: string; + isVerified: boolean; + verificationTaskId: string | null; + verificationTaskActive: boolean; + verifiedAt: Date | null; + verificationError: string | null; + } | null; /** The initial prompt for the session, null if not yet loaded or not found. */ prompt: AcpUiMessage | null; @@ -157,7 +164,9 @@ export function useTaskSession( [sessionQuery.data], ); - const sessionState = sessionQuery.data?.sessionState ?? 'not-found'; + const sessionState = + sessionQuery.data?.sessionState ?? + (sessionQuery.isError ? 'error' : 'not-found'); const runId = sessionQuery.data?.taskRun?.id; const tokenEnabled = sessionState === 'interactive' && !!runId; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx index ca99309a..700865e9 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx @@ -199,6 +199,51 @@ describe('SandboxPage', () => { expect(screen.queryByTestId('sandbox-provider')).not.toBeInTheDocument(); }); + it('does not open a live sandbox when the session has no task run', () => { + useTaskSessionMock.mockReturnValue({ + ...baseSession, + taskRun: undefined, + sessionState: 'interactive', + }); + useTaskMessageEnvelopesMock.mockReturnValue({ + data: [], + }); + + renderPage(); + + expect( + screen.getByText( + 'This task session is still preparing. Refresh the page or try again in a moment.', + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('sandbox-provider')).not.toBeInTheDocument(); + }); + + it('renders paused setup onboarding tasks with the live task surface', () => { + useTaskSessionMock.mockReturnValue({ + ...baseSession, + sessionState: 'interactive', + task: { + ...baseSession.task, + workflow: 'setup_onboarding', + }, + taskRun: { + ...baseSession.taskRun, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + }, + }); + useTaskMessageEnvelopesMock.mockReturnValue({ + data: [], + }); + + renderPage(); + + expect(screen.getByTestId('sandbox-provider')).toBeInTheDocument(); + expect(screen.getByTestId('live-content')).toBeInTheDocument(); + expect(screen.queryByTestId('historical-provider')).not.toBeInTheDocument(); + }); + it('renders the transcript while booting when the initial prompt is visible even before messages exist', () => { useTaskSessionMock.mockReturnValue({ ...baseSession, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index 52980a1b..fe444136 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -9,7 +9,7 @@ import { } from 'react'; import { useParams } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; -import { CircleSlash } from '@/components/system'; +import { CircleSlash, TriangleAlert } from '@/components/system'; import { TaskPayloadKind, @@ -185,6 +185,19 @@ export default function SandboxPage() { ); } + if (sessionState === 'error') { + return ( + + } + iconClassName="text-amber-500 pt-0" + containerClassName="[&>div]:items-center" + description="This task could not be loaded. Refresh the page or try again in a moment." + /> + + ); + } + if (sessionState === 'not-found') { return ( @@ -198,6 +211,19 @@ export default function SandboxPage() { ); } + if (!taskRun) { + return ( + + } + iconClassName="text-amber-500 pt-0" + containerClassName="[&>div]:items-center" + description="This task session is still preparing. Refresh the page or try again in a moment." + /> + + ); + } + if (sessionState === 'historical' || sessionState === 'resuming') { return (
{session.draftPrompt && ( @@ -264,8 +290,8 @@ export default function SandboxPage() {
{session.draftPrompt && ( @@ -278,7 +304,7 @@ export default function SandboxPage() { return ( ({ - buildAcpRenderBlocksMock: vi.fn(() => [ - { - kind: 'message', - msg: { id: 'visible-message' }, - }, - ]), - messagesPropsMock: vi.fn(), - sandboxLogsTerminalPropsMock: vi.fn(), - sandboxProviderPropsMock: vi.fn(), - showDebugUiState: { enabled: false }, - userState: { - isSignedIn: true, - user: { - featureFlags: { - ShowDebugUISetting: false, - }, - }, - }, +const { useTaskSessionMock, useEnvironmentMock } = vi.hoisted(() => ({ useTaskSessionMock: vi.fn(), useEnvironmentMock: vi.fn(), - useSandboxLogsMock: vi.fn(), - useSandboxMessagesMock: vi.fn(), - useSandboxTaskStatusDisplayMock: vi.fn(), - usePendingUserInputRequestStateMock: vi.fn(), })); vi.mock('@/app/(sandbox)/task/[taskId]/hooks', () => ({ - HistoricalSandboxProvider: ({ children }: { children: ReactNode }) => ( - <>{children} - ), - SandboxProvider: (props: { children: ReactNode }) => { - sandboxProviderPropsMock(props); - - return <>{props.children}; - }, useTaskSession: (...args: unknown[]) => useTaskSessionMock(...args), - useTaskMessageEnvelopes: () => ({ - data: [], - isPending: false, - isSuccess: true, - isError: false, - }), - useSandboxMessages: useSandboxMessagesMock, - useSandboxTaskStatusDisplay: useSandboxTaskStatusDisplayMock, })); vi.mock('@/hooks/environments', () => ({ useEnvironment: (...args: unknown[]) => useEnvironmentMock(...args), })); -vi.mock('@/app/(sandbox)/task/[taskId]/Messages', () => ({ - Messages: (props: unknown) => { - messagesPropsMock(props); - return
; - }, -})); - -vi.mock('@/app/(sandbox)/task/[taskId]/messages/acp/render-blocks', () => ({ - buildAcpRenderBlocks: buildAcpRenderBlocksMock, -})); - -vi.mock('@/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel', () => ({ - PendingEnvVarRequestPanel: () =>
, -})); - -vi.mock('@/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel', () => ({ - PendingUserInputRequestPanel: () =>
, - PendingUserInputRequestStateProvider: ({ - children, - }: { - children: ReactNode; - }) => <>{children}, - usePendingUserInputRequestState: () => usePendingUserInputRequestStateMock(), - useOptionalPendingUserInputRequestState: () => - usePendingUserInputRequestStateMock(), -})); - -vi.mock('@/app/(sandbox)/task/[taskId]/QueuedMessages', () => ({ - QueuedMessages: () =>
, -})); - -vi.mock('@/app/(sandbox)/task/[taskId]/TodoList', () => ({ - TodoList: () =>
, -})); - -vi.mock('@/app/(sandbox)/task/[taskId]/prompt-input/PromptInput', () => ({ - PromptInput: () =>
, -})); - -vi.mock('@/components/sandbox', () => ({ - SandboxLogsTerminal: (props: unknown) => { - sandboxLogsTerminalPropsMock(props); - return
; - }, - TaskStatusIndicator: () => Status, - useSandboxLogs: (...args: unknown[]) => useSandboxLogsMock(...args), -})); - -vi.mock('@/components/system', () => ({ - Spinner: ({ className }: { className?: string }) => ( -
- ), -})); - -vi.mock('@/hooks/useNarrationMode', () => ({ - useNarrationMode: () => ({ - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }), -})); - -vi.mock('@/hooks/useShowDebugUI', () => ({ - useShowDebugUI: () => ({ - isDebugUIVisible: showDebugUiState.enabled, - isLoading: false, - isUpdating: false, - setDebugUIVisible: vi.fn(), - }), -})); - -vi.mock('@/hooks/useUser', () => ({ - useUser: () => - userState.isSignedIn - ? { isSignedIn: true as const, user: userState.user } - : { isSignedIn: false as const, user: null, authStatus: 'signed-out' }, -})); - -vi.mock('@/components/ai-elements', () => ({ - Shimmer: ({ - children, - ...props - }: { children: ReactNode } & HTMLAttributes) => ( - {children} - ), -})); - -import { - EnvironmentDefinitionAgentTaskPanel, - useEnvironmentDefinitionAgentState, -} from './EnvironmentDefinitionAgentTask'; - -describe('EnvironmentDefinitionAgentTaskPanel', () => { - beforeEach(() => { - vi.clearAllMocks(); - buildAcpRenderBlocksMock.mockReturnValue([ - { - kind: 'message', - msg: { id: 'visible-message' }, - }, - ]); - useSandboxMessagesMock.mockReturnValue({ - messages: [{ id: 'message-1' }], - }); - useSandboxTaskStatusDisplayMock.mockReturnValue({ - lastErrorMessage: null, - }); - userState.isSignedIn = true; - userState.user.featureFlags.ShowDebugUISetting = false; - showDebugUiState.enabled = false; - sandboxLogsTerminalPropsMock.mockReset(); - useSandboxLogsMock.mockReturnValue({ - logs: [], - error: null, - isConnected: false, - }); - usePendingUserInputRequestStateMock.mockReturnValue({ - shouldHidePromptInput: false, - }); - }); - - it('renders pending input requests in the prompt stack', () => { - render( - , - ); - - const pendingEnvVars = screen.getByTestId('pending-env-vars'); - const todoList = screen.getByTestId('todo-list'); - const pendingUserInput = screen.getByTestId('pending-user-input'); - const queuedMessages = screen.getByTestId('queued-messages'); - const promptInput = screen.getByTestId('prompt-input'); - - expect(pendingEnvVars.nextElementSibling).toBe(todoList); - expect(todoList.nextElementSibling).toBe(pendingUserInput); - expect(pendingUserInput.nextElementSibling).toBe(queuedMessages); - expect(queuedMessages.nextElementSibling).toBe(promptInput.parentElement); - }); - - it('passes refreshConnection into the live sandbox provider', () => { - const refreshConnection = vi.fn(); - - render( - , - ); - - expect(sandboxProviderPropsMock).toHaveBeenCalledWith( - expect.objectContaining({ - taskId: 'task-1', - url: 'http://localhost:3001', - token: 'token', - refreshConnection, - }), - ); - }); - - it('hides the free-form prompt input while option input requests are active', () => { - usePendingUserInputRequestStateMock.mockReturnValue({ - shouldHidePromptInput: true, - }); - - render( - , - ); - - expect(screen.getByTestId('pending-user-input')).toBeInTheDocument(); - expect(screen.getByTestId('prompt-input').parentElement).toHaveClass( - 'hidden', - ); - }); - - it('can keep secure env var requests visible without the todo list or prompt input', () => { - render( - , - ); - - expect(screen.getByTestId('pending-env-vars')).toBeInTheDocument(); - expect(screen.queryByTestId('todo-list')).not.toBeInTheDocument(); - expect(screen.queryByTestId('queued-messages')).not.toBeInTheDocument(); - expect(screen.queryByTestId('prompt-input')).not.toBeInTheDocument(); - }); - - it('keeps secure env var requests visible for historical sessions', () => { - render( - , - ); - - expect(screen.getByTestId('pending-env-vars')).toBeInTheDocument(); - expect(screen.queryByTestId('pending-user-input')).not.toBeInTheDocument(); - expect(screen.queryByTestId('queued-messages')).not.toBeInTheDocument(); - expect(screen.queryByTestId('todo-list')).not.toBeInTheDocument(); - expect(screen.queryByTestId('prompt-input')).not.toBeInTheDocument(); - }); - - it('forwards narration mode to the message panel when requested', () => { - render( - , - ); - - expect(messagesPropsMock).toHaveBeenCalledWith( - expect.objectContaining({ - messageUiOptions: { - displayMode: 'narration', - hideNewTaskAction: true, - }, - }), - ); - }); - - it('shows a loading placeholder before the first live update arrives', () => { - useSandboxMessagesMock.mockReturnValue({ - messages: [], - }); - buildAcpRenderBlocksMock.mockReturnValue([]); - - render( - , - ); - - expect(screen.getByText('Waiting for setup details')).toBeInTheDocument(); - expect( - screen.getByText( - 'Roomote will stream status updates and secure follow-up requests here as soon as the setup agent reports them.', - ), - ).toBeInTheDocument(); - expect(screen.getByTestId('spinner')).toBeInTheDocument(); - expect(screen.queryByTestId('messages')).not.toBeInTheDocument(); - expect(screen.getByTestId('pending-env-vars')).toBeInTheDocument(); - }); - - it('keeps the loading placeholder when transcript updates are not yet displayable', () => { - useSandboxMessagesMock.mockReturnValue({ - messages: [ - { - id: 'message-hidden', - kind: 'text', - role: 'user', - partial: false, - sessionId: 'session-1', - updateType: 'user_prompt', - text: '$environment-setup', - data: {}, - }, - ], - }); - buildAcpRenderBlocksMock.mockReturnValue([]); - - render( - , - ); - - expect(screen.getByText('Waiting for setup details')).toBeInTheDocument(); - expect(screen.queryByTestId('messages')).not.toBeInTheDocument(); - }); - - it('treats internal transcript rows as visible when debug UI is enabled', () => { - useSandboxMessagesMock.mockReturnValue({ - messages: [ - { - id: 'message-internal', - kind: 'tool_result', - }, - ], - }); - userState.user.featureFlags.ShowDebugUISetting = true; - showDebugUiState.enabled = true; - buildAcpRenderBlocksMock.mockImplementation((...args: unknown[]) => { - const options = args[1] as { showInternalMessages?: boolean } | undefined; - - return options?.showInternalMessages - ? [ - { - kind: 'message', - msg: { id: 'internal-visible' }, - }, - ] - : []; - }); - - render( - , - ); - - expect(buildAcpRenderBlocksMock).toHaveBeenCalledWith( - [{ id: 'message-internal', kind: 'tool_result' }], - expect.objectContaining({ - displayMode: 'default', - showInternalMessages: true, - }), - ); - expect( - screen.queryByText('Waiting for setup details'), - ).not.toBeInTheDocument(); - expect(screen.getByTestId('messages')).toBeInTheDocument(); - }); - - it('keeps startup copy visible while booting before visible logs arrive', () => { - useSandboxLogsMock.mockReturnValue({ - logs: [], - error: null, - isConnected: true, - }); - - render( - , - ); - - expect( - screen.getByText('Starting the environment definition agent...'), - ).toBeInTheDocument(); - expect(screen.getByTestId('sandbox-logs')).toBeInTheDocument(); - expect(sandboxLogsTerminalPropsMock).toHaveBeenCalledWith( - expect.objectContaining({ - loadingMessage: 'Starting the environment definition agent...', - }), - ); - }); - - it('hides startup copy once the agent boot fails', () => { - useSandboxLogsMock.mockReturnValue({ - logs: [], - error: null, - isConnected: true, - }); - - render( - , - ); - - expect( - screen.queryByText('Starting the environment definition agent...'), - ).not.toBeInTheDocument(); - expect( - screen.getByText( - 'Sorry, the agent failed to start. Try again or switch to editing the YAML directly.', - ), - ).toBeInTheDocument(); - expect(sandboxLogsTerminalPropsMock).toHaveBeenCalledWith( - expect.objectContaining({ - loadingMessage: 'The environment definition agent failed to start.', - }), - ); - }); -}); +import { useEnvironmentDefinitionAgentState } from './EnvironmentDefinitionAgentTask'; describe('useEnvironmentDefinitionAgentState', () => { beforeEach(() => { diff --git a/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx b/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx index b56b0dec..68beebf0 100644 --- a/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx @@ -1,12 +1,10 @@ 'use client'; -import { useEffect, useMemo, useRef } from 'react'; -import Link from 'next/link'; +import { useEffect, useMemo } from 'react'; import { getEnvironmentDefinitionIdFromPayload, isExitedRunStatus, - WAITING_FOR_SANDBOX_PROVIDER_TASK_PHASE, } from '@roomote/types'; import { @@ -15,43 +13,10 @@ import { isEnvironmentDefinitionSuccessStatus, isEnvironmentDefinitionTerminalSuccessStatus, } from '@/lib/environment-definition'; -import { cn } from '@/lib/utils'; import { useEnvironment } from '@/hooks/environments'; -import { - SandboxLogsTerminal, - TaskStatusIndicator, - useSandboxLogs, -} from '@/components/sandbox'; -import { Spinner } from '@/components/system'; -import { Shimmer } from '@/components/ai-elements'; - -import { - HistoricalSandboxProvider, - SandboxProvider, - useTaskSession, - useTaskMessageEnvelopes, - useSandboxMessages, - useSandboxTaskStatusDisplay, -} from '@/app/(sandbox)/task/[taskId]/hooks'; -import { - type MessagesHandle, - Messages, -} from '@/app/(sandbox)/task/[taskId]/Messages'; -import { buildAcpRenderBlocks } from '@/app/(sandbox)/task/[taskId]/messages/acp/render-blocks'; -import { useInternalTranscriptRowsVisible } from '@/app/(sandbox)/task/[taskId]/useInternalTranscriptRowsVisible'; -import { PendingEnvVarRequestPanel } from '@/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel'; -import { - PendingUserInputRequestPanel, - PendingUserInputRequestStateProvider, - useOptionalPendingUserInputRequestState, -} from '@/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel'; -import { QueuedMessages } from '@/app/(sandbox)/task/[taskId]/QueuedMessages'; -import { TodoList } from '@/app/(sandbox)/task/[taskId]/TodoList'; -import { PromptInput } from '@/app/(sandbox)/task/[taskId]/prompt-input/PromptInput'; -import type { MessageUiOptions } from '@/components/ai-elements/message-ui-options'; -import { useNarrationMode } from '@/hooks/useNarrationMode'; +import { useTaskSession } from '@/app/(sandbox)/task/[taskId]/hooks'; export type SelectedRepositorySummary = { id: string; @@ -158,8 +123,33 @@ export function useEnvironmentDefinitionAgentState({ const taskIsActive = !!session.taskRun && !isExitedRunStatus(session.taskRun.status); + // Resolve verification state from the linked/updated environment metadata, + // which is the source of truth for the web UI. Setup/configuration success is + // distinct from environment verification, which continues asynchronously. + const resolvedEnvironment = + mode === 'create' ? matchingEnvironment : updatedEnvironment; + + const verificationSucceeded = resolvedEnvironment?.isVerified === true; + const verificationFailed = + !!resolvedEnvironment && + !resolvedEnvironment.isVerified && + resolvedEnvironment.verificationError !== null && + resolvedEnvironment.verificationError !== undefined; + const verificationPending = + !!resolvedEnvironment && + !resolvedEnvironment.isVerified && + !verificationFailed && + resolvedEnvironment.verificationTaskId !== null && + resolvedEnvironment.verificationTaskId !== undefined && + resolvedEnvironment.verificationTaskActive === true; + const verificationTaskId = resolvedEnvironment?.verificationTaskId ?? null; + const verificationError = resolvedEnvironment?.verificationError ?? null; + useEffect(() => { - if (succeeded || failed) { + // Keep polling while the definition task is unresolved, and also while the + // environment exists but verification is still pending, so the UI can move + // from configured -> pending -> verified/failed on its own. + if ((succeeded || failed) && !verificationPending) { return; } @@ -192,6 +182,7 @@ export function useEnvironmentDefinitionAgentState({ refetchEnvironment, refetchLinkedEnvironment, succeeded, + verificationPending, ]); return { @@ -201,428 +192,10 @@ export function useEnvironmentDefinitionAgentState({ taskIsActive, matchingEnvironment, updatedEnvironment, + verificationSucceeded, + verificationFailed, + verificationPending, + verificationTaskId, + verificationError, }; } - -export function EnvironmentDefinitionAgentTaskPanel({ - session, - title = 'Environment Definition Agent', - className, - showHeader = true, - showPendingEnvVarRequests, - showPendingUserInputRequests, - showQueuedMessages, - showTodoList, - showPromptInput = true, - messageUiOptions, -}: { - session: ReturnType; - title?: string; - className?: string; - showHeader?: boolean; - showPendingEnvVarRequests?: boolean; - showPendingUserInputRequests?: boolean; - showQueuedMessages?: boolean; - showTodoList?: boolean; - showPromptInput?: boolean; - messageUiOptions?: MessageUiOptions; -}) { - const resolvedShowPendingEnvVarRequests = - showPendingEnvVarRequests ?? showPromptInput; - const resolvedShowPendingUserInputRequests = - showPendingUserInputRequests ?? showPromptInput; - const resolvedShowQueuedMessages = showQueuedMessages ?? showPromptInput; - const resolvedShowTodoList = showTodoList ?? showPromptInput; - - return ( -
-
- {showHeader ? ( -
- {title} - -
- ) : null} -
- -
-
-
- ); -} - -function EnvironmentDefinitionConversationBody({ - session, - showPendingEnvVarRequests, - showPendingUserInputRequests, - showQueuedMessages, - showTodoList, - showPromptInput, - messageUiOptions, -}: { - session: ReturnType; - showPendingEnvVarRequests: boolean; - showPendingUserInputRequests: boolean; - showQueuedMessages: boolean; - showTodoList: boolean; - showPromptInput: boolean; - messageUiOptions?: MessageUiOptions; -}) { - const historyEnvelopesQuery = useTaskMessageEnvelopes(session.taskId, { - enabled: true, - }); - const runId = session.taskRun?.id; - const isBooting = - session.sessionState === 'booting' || session.sessionState === 'resuming'; - const isBootFailed = session.sessionState === 'boot-failed'; - const showStartupSurface = isBooting || isBootFailed; - const showLogs = showStartupSurface && !!runId; - const isWaitingForSandboxProvider = - session.taskRun?.taskPhase === WAITING_FOR_SANDBOX_PROVIDER_TASK_PHASE; - const providerProvisioningError = isWaitingForSandboxProvider - ? session.taskRun?.error - : null; - - const { - logs: sandboxLogs, - error: logsError, - isConnected: logsConnected, - } = useSandboxLogs({ - runId, - enabled: showLogs, - }); - - if (session.isLoading) { - return ( -
- Loading... -
- ); - } - - if (isWaitingForSandboxProvider) { - return ( -
-

Waiting for sandbox provider

-

- {providerProvisioningError ?? - 'Roomote is preparing the selected sandbox provider. This task will start automatically when it is ready.'} -

- {providerProvisioningError ? ( - - Retry provisioning in Sandbox settings - - ) : null} -
- ); - } - - if (showStartupSurface) { - const startupLogLoadingMessage = isBootFailed - ? 'The environment definition agent failed to start.' - : 'Starting the environment definition agent...'; - - return ( -
- {isBooting ? ( -
- - Starting the environment definition agent... - -
- ) : null} - {showLogs ? ( - - ) : null} - {isBootFailed && ( -
- Sorry, the agent failed to start. Try again or switch to editing the - YAML directly. -
- )} -
- ); - } - - if (session.sessionState === 'historical') { - return ( - - - - ); - } - - return ( - - Loading... -
- } - > - - - ); -} - -const INVALID_OPENAI_KEY_RE = /Incorrect API key provided:/i; - -const UNAUTHORIZED_RE = /\b401\b|\bunauthorized\b/i; - -function EnvironmentDefinitionMessagesPane({ - session, - showPendingEnvVarRequests, - showPendingUserInputRequests, - showQueuedMessages, - showTodoList, - showPromptInput, - messageUiOptions, -}: { - session: ReturnType; - showPendingEnvVarRequests: boolean; - showPendingUserInputRequests: boolean; - showQueuedMessages: boolean; - showTodoList: boolean; - showPromptInput: boolean; - messageUiOptions?: MessageUiOptions; -}) { - const { messages } = useSandboxMessages(); - const { lastErrorMessage } = useSandboxTaskStatusDisplay(); - const { enabled: narrationModeEnabled } = useNarrationMode(); - const showInternalMessages = useInternalTranscriptRowsVisible(); - const messagesRef = useRef(null); - const didInitialScrollRef = useRef(false); - const resolvedDisplayMode = - messageUiOptions?.displayMode ?? - (narrationModeEnabled ? 'narration' : 'default'); - const authFailureHint = useMemo( - () => - getEnvironmentDefinitionAuthFailureHint({ - lastErrorMessage, - taskRunLog: session.taskRun?.log, - }), - [lastErrorMessage, session.taskRun?.log], - ); - const hasVisibleConversationContent = useMemo( - () => - buildAcpRenderBlocks(messages, { - displayMode: resolvedDisplayMode, - showInternalMessages, - }).length > 0, - [messages, resolvedDisplayMode, showInternalMessages], - ); - - useEffect(() => { - didInitialScrollRef.current = false; - }, [session.taskId]); - - useEffect(() => { - if (didInitialScrollRef.current || messages.length === 0) { - return; - } - - didInitialScrollRef.current = true; - requestAnimationFrame(() => { - void messagesRef.current?.scrollToBottom(); - }); - }, [messages.length]); - - const showBottomPanel = - showPendingEnvVarRequests || - showPendingUserInputRequests || - showQueuedMessages || - showTodoList || - showPromptInput; - const isWaitingForFirstUpdate = - !hasVisibleConversationContent && - !!session.taskRun && - !isExitedRunStatus(session.taskRun.status) && - !authFailureHint; - - return ( -
- {isWaitingForFirstUpdate ? ( -
-
- -
-

- Waiting for setup details -

-

- Roomote will stream status updates and secure follow-up requests - here as soon as the setup agent reports them. -

-
-
-
- ) : ( - - )} - {messages.length <= 1 && authFailureHint && ( -
- {authFailureHint} -
- )} - {showBottomPanel && - (showPendingUserInputRequests ? ( - - - - ) : ( - - ))} -
- ); -} - -function EnvironmentDefinitionInputStack({ - session, - showPendingEnvVarRequests, - showPendingUserInputRequests, - showQueuedMessages, - showTodoList, - showPromptInput, -}: { - session: ReturnType; - showPendingEnvVarRequests: boolean; - showPendingUserInputRequests: boolean; - showQueuedMessages: boolean; - showTodoList: boolean; - showPromptInput: boolean; -}) { - const pendingUserInputState = useOptionalPendingUserInputRequestState(); - const hidePromptInput = pendingUserInputState?.shouldHidePromptInput ?? false; - - return ( -
- {showPendingEnvVarRequests ? ( - - ) : null} - {showTodoList ? : null} - {showPendingUserInputRequests ? : null} - {showQueuedMessages ? : null} - {showPromptInput ? ( -
- {}} - onCommandSearchOpen={() => {}} - taskRun={session.taskRun} - hasTransportError={session.hasTransportError} - showInputMenu={false} - showTaskStatus={false} - showTaskToolsMenu={false} - placeholder="Message the agent..." - /> -
- ) : null} -
- ); -} - -function getEnvironmentDefinitionAuthFailureHint({ - lastErrorMessage, - taskRunLog, -}: { - lastErrorMessage?: string; - taskRunLog?: string | null; -}): string | null { - const statusError = - typeof lastErrorMessage === 'string' ? lastErrorMessage : ''; - - const recentLog = taskRunLog ? taskRunLog.slice(-12_000) : ''; - - const hasAuthFailure = - INVALID_OPENAI_KEY_RE.test(statusError) || - INVALID_OPENAI_KEY_RE.test(recentLog) || - (UNAUTHORIZED_RE.test(statusError) && /OPENAI_API_KEY/i.test(statusError)); - - if (!hasAuthFailure) { - return null; - } - - return 'The environment definition agent could not authenticate with the configured model provider. Check R_MODEL, R_SMALL_MODEL, R_VISION_MODEL, and the matching provider API key env vars, then retry.'; -} diff --git a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx new file mode 100644 index 00000000..6ad8974b --- /dev/null +++ b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx @@ -0,0 +1,204 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +import type { EnvironmentWithMeta } from '@/trpc/commands/environments'; +import { + ArrowUpRightIcon, + Badge, + BasicTooltip, + Check, + HelpCircle, + Loader2, + type LucideIcon, + X, +} from '@/components/system'; + +export type EnvironmentVerificationState = + | 'verified' + | 'in_progress' + | 'failed' + | 'configured'; + +export const environmentVerificationDisplay: Record< + EnvironmentVerificationState, + { + Icon: LucideIcon; + label: string; + iconClassName?: string; + } +> = { + verified: { + Icon: Check, + label: 'Verified', + }, + in_progress: { + Icon: Loader2, + label: 'Verification in progress', + iconClassName: 'animate-spin', + }, + failed: { + Icon: X, + label: 'Verification failed', + }, + configured: { + Icon: HelpCircle, + label: 'Configured', + }, +}; + +export function getEnvironmentVerificationState( + environment: Pick< + EnvironmentWithMeta, + | 'isVerified' + | 'verificationTaskId' + | 'verificationTaskActive' + | 'verificationError' + >, +): EnvironmentVerificationState { + if (environment.isVerified) { + return 'verified'; + } + + if (environment.verificationError) { + return 'failed'; + } + + // Only show "in progress" while the verification task actually has an active + // run. A stale task id from a crashed or unreported attempt falls back to + // "configured" instead of appearing stuck forever. + if (environment.verificationTaskId && environment.verificationTaskActive) { + return 'in_progress'; + } + + return 'configured'; +} + +export function EnvironmentVerificationBadge({ + env, +}: { + env: EnvironmentWithMeta; +}) { + const state = getEnvironmentVerificationState(env); + const { Icon, iconClassName, label } = environmentVerificationDisplay[state]; + const hasVerificationTask = Boolean(env.verificationTaskId); + const badgeClassName = hasVerificationTask ? 'gap-1 cursor-pointer' : 'gap-1'; + const wrapBadge = (badge: ReactNode) => + hasVerificationTask ? ( + + {badge} + + ) : ( + badge + ); + + switch (state) { + case 'verified': + return ( + +

+ {env.name} is verified and ready to use. + {hasVerificationTask + ? ' Click to see the verification task.' + : ''} +

+ {env.verifiedAt ? ( +

+ Verified{' '} + {formatDistanceToNow(env.verifiedAt, { addSuffix: true })} +

+ ) : null} +
+ } + > + {wrapBadge( + + + {label} + {hasVerificationTask ? : null} + , + )} + + ); + case 'in_progress': + return ( + + Roomote is checking that this environment works. You can keep + using it while verification finishes. +
+ } + > + {wrapBadge( + + + {label} + {hasVerificationTask ? : null} + , + )} + + ); + case 'failed': + return ( + + Roomote could not verify that this environment works. It is still + usable; retry verification after checking the configuration. + {env.verificationError ? ( +

{env.verificationError}

+ ) : null} +
+ } + > + {wrapBadge( + + + {label} + {hasVerificationTask ? : null} + , + )} + + ); + case 'configured': + default: + return ( + + {env.name} is configured but has not been verified for its current + configuration. + {hasVerificationTask + ? ' Click to see the verification task.' + : ''} +
+ } + > + {wrapBadge( + + + {label} + {hasVerificationTask ? : null} + , + )} + + ); + } +} diff --git a/apps/web/src/components/settings/environments/Environments.client.test.tsx b/apps/web/src/components/settings/environments/Environments.client.test.tsx index 2d7368ec..e14f0462 100644 --- a/apps/web/src/components/settings/environments/Environments.client.test.tsx +++ b/apps/web/src/components/settings/environments/Environments.client.test.tsx @@ -54,6 +54,11 @@ const state = vi.hoisted(() => ({ snapshotExpiresAt: null, }, }, + isVerified: true, + verificationTaskId: 'task-verify-1' as string | null, + verificationTaskActive: true, + verifiedAt: new Date('2026-03-25T09:00:00.000Z'), + verificationError: null, }, ], })); @@ -107,6 +112,11 @@ vi.mock('@/hooks/environments', () => ({ isPending: false, mutateAsync: vi.fn(), }), + useRetryEnvironmentVerification: () => ({ + isPending: false, + variables: undefined, + mutate: vi.fn(), + }), })); vi.mock('@/hooks/snapshots', () => ({ @@ -152,6 +162,7 @@ vi.mock('@/components/system', () => ({ {children} ), BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}, + ArrowUpRightIcon: Icon, BookMarked: Icon, Button: ({ children, @@ -175,6 +186,7 @@ vi.mock('@/components/system', () => ({ ); }, Camera: Icon, + Check: Icon, CheckCircle2: Icon, ChevronDown: Icon, Collapsible: ({ @@ -294,6 +306,7 @@ vi.mock('@/components/system', () => ({

{children}

), Github: Icon, + HelpCircle: Icon, Input: (props: InputHTMLAttributes) => , Label: ({ children, @@ -304,6 +317,8 @@ vi.mock('@/components/system', () => ({ Loader2: Icon, Pencil: Icon, Plus: Icon, + RefreshCw: Icon, + SearchCheck: Icon, Popover: ({ children }: { children: ReactNode }) =>
{children}
, PopoverContent: ({ children, @@ -381,6 +396,11 @@ describe('Environments', () => { snapshotExpiresAt: null, }, }, + isVerified: true, + verificationTaskId: 'task-verify-1', + verificationTaskActive: true, + verifiedAt: new Date('2026-03-25T09:00:00.000Z'), + verificationError: null, }, ]; state.createSnapshot.mockClear(); @@ -415,28 +435,49 @@ describe('Environments', () => { ).toBeInTheDocument(); }); - it('shows descriptions and keeps environment details collapsed until expanded', () => { + it('shows verification and repositories while keeping snapshot details collapsed until expanded', () => { render(); - expect( - screen.getByText('Main Environment description'), - ).toBeInTheDocument(); expect(screen.queryByTitle('Edit details')).not.toBeInTheDocument(); expect(screen.getByTitle('Edit environment')).toHaveAttribute( 'href', '/settings/environments/env-1/edit', ); - expect(screen.queryByText('acme/api')).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Open verification task' }), + ).toHaveAttribute('href', '/task/task-verify-1'); + expect( + screen.queryByRole('link', { name: 'View task' }), + ).not.toBeInTheDocument(); + expect( + screen.getByTitle('Re-verify environment config'), + ).toBeInTheDocument(); + expect(screen.getByText('acme/api')).toBeInTheDocument(); expect( screen.queryByTitle('Refresh modal snapshot'), ).not.toBeInTheDocument(); fireEvent.click(screen.getByTitle('Toggle environment details')); - expect(screen.getByText('acme/api')).toBeInTheDocument(); expect(screen.getByTitle('Refresh modal snapshot')).toBeInTheDocument(); }); + it('does not link verified badges when there is no verification task', () => { + state.environments = [ + { + ...state.environments[0]!, + verificationTaskId: null, + }, + ]; + + render(); + + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Open verification task' }), + ).not.toBeInTheDocument(); + }); + it('shows snapshot controls on the environments settings page', () => { render(); diff --git a/apps/web/src/components/settings/environments/Environments.tsx b/apps/web/src/components/settings/environments/Environments.tsx index f730c859..488df7e2 100644 --- a/apps/web/src/components/settings/environments/Environments.tsx +++ b/apps/web/src/components/settings/environments/Environments.tsx @@ -12,6 +12,7 @@ import { ChevronDown, BookMarked, Camera, + SearchCheck, X, VectorSquare, TriangleAlert, @@ -24,6 +25,7 @@ import { useEnvironments, useDeleteEnvironment, useDuplicateEnvironment, + useRetryEnvironmentVerification, } from '@/hooks/environments'; import { useCreateEnvironmentSnapshot, @@ -49,6 +51,10 @@ import { Section } from '@/components/settings'; import { DuplicateEnvironmentDialog } from './DuplicateEnvironmentDialog'; import { SnapshotStatusBadge } from './SnapshotStatusBadge'; +import { + EnvironmentVerificationBadge, + getEnvironmentVerificationState, +} from './EnvironmentVerificationStatus'; function getEnvironmentSnapshot( environment: EnvironmentWithMeta, @@ -57,10 +63,6 @@ function getEnvironmentSnapshot( return environment.snapshots[provider]; } -function getEnvironmentDescription(environment: EnvironmentWithMeta) { - return environment.description || environment.config.description || ''; -} - export function Environments() { const [environmentToDuplicate, setEnvironmentToDuplicate] = useState(); @@ -69,6 +71,7 @@ export function Environments() { const repositories = useRepositories(); const deleteEnvironment = useDeleteEnvironment(); const duplicateEnvironment = useDuplicateEnvironment(); + const retryVerification = useRetryEnvironmentVerification(); const createEnvironmentSnapshot = useCreateEnvironmentSnapshot(); const clearEnvironmentSnapshot = useClearEnvironmentSnapshot(); const allSnapshotProviders: ComputeProvider[] = ['modal', 'e2b']; @@ -123,15 +126,18 @@ export function Environments() { ) : (
{environments.data.map((env) => { - const description = getEnvironmentDescription(env); const visibleSnapshotProviders: ComputeProvider[] = allSnapshotProviders; + const verificationState = getEnvironmentVerificationState(env); + const isRetryingVerification = + retryVerification.isPending && + retryVerification.variables?.environmentId === env.id; return (
-
+

{env.name}

{env.declarativeSource ? ( @@ -142,11 +148,20 @@ export function Environments() { ) : null}
- {description ? ( -

- {description} -

- ) : null} +
+ + {env.config.repositories?.map((repo, idx) => ( +
+ + + {repo.repository} + +
+ ))} +
@@ -165,6 +180,26 @@ export function Environments() { + {verificationState !== 'in_progress' ? ( + + ) : null}