From 330f0c29f1ac1ff910e9c7b268e3c9fb28529455 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 13:14:30 +0000 Subject: [PATCH 01/21] [Feat] Add persisted environment verification state --- .changeset/environment-verification-state.md | 5 + .../__tests__/recordVerification.test.ts | 208 + .../environments/createEnvironment.ts | 39 + apps/api/src/handlers/environments/index.ts | 2 + .../environments/recordVerification.ts | 221 + .../environments/updateEnvironment.ts | 12 +- ...SetupOnboardingAgentWidget.client.test.tsx | 30 +- .../setup/SetupOnboardingAgentWidget.tsx | 83 +- .../src/app/(onboarding)/setup/StepInvoke.tsx | 2 +- .../EnvironmentDefinitionAgentTask.tsx | 33 +- .../EnvironmentVerificationStatus.tsx | 181 + .../environments/Environments.client.test.tsx | 16 + .../settings/environments/Environments.tsx | 3 + apps/web/src/hooks/environments/index.ts | 1 + .../environments/useEnvironmentMutations.ts | 28 + .../src/hooks/environments/useEnvironments.ts | 23 +- .../trpc/commands/environments/index.test.ts | 99 + .../src/trpc/commands/environments/index.ts | 155 + apps/web/src/trpc/routers/_app.ts | 7 + .../__tests__/create-environment.test.ts | 82 + .../__tests__/tool-descriptions.test.ts | 13 +- .../roomote-mcp-server/create-environment.ts | 38 +- .../src/mcp/roomote-mcp-server/index.ts | 45 +- .../roomote-mcp-server/tasks-api-client.ts | 27 + .../src/mcp/roomote-mcp-server/types.ts | 6 + .../standard/environment-setup/SKILL.md | 5 + .../db/drizzle/0011_flimsy_sally_floyd.sql | 4 + packages/db/drizzle/meta/0011_snapshot.json | 9043 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../environment-verification.test.ts | 213 + .../db/src/lib/declarative-environments.ts | 1 + .../db/src/lib/environment-definitions.ts | 140 +- packages/db/src/schema.ts | 17 + .../types/src/environment-definition-tasks.ts | 16 + packages/types/src/task-runs.ts | 9 + 35 files changed, 10778 insertions(+), 36 deletions(-) create mode 100644 .changeset/environment-verification-state.md create mode 100644 apps/api/src/handlers/environments/__tests__/recordVerification.test.ts create mode 100644 apps/api/src/handlers/environments/recordVerification.ts create mode 100644 apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx create mode 100644 packages/db/drizzle/0011_flimsy_sally_floyd.sql create mode 100644 packages/db/drizzle/meta/0011_snapshot.json create mode 100644 packages/db/src/lib/__tests__/environment-verification.test.ts 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..60810b2d 100644 --- a/apps/api/src/handlers/environments/createEnvironment.ts +++ b/apps/api/src/handlers/environments/createEnvironment.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { and, + beginEnvironmentVerification, createEnvironmentConfigVersionSnapshot, db, environmentRepositoryMappings, @@ -172,6 +173,39 @@ export async function attachEnvironmentIdToTaskRun( .where(eq(taskRuns.id, taskRun.id)); } +/** + * Register the calling task run as the current verification attempt for an + * 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`; storing the caller's + * taskId here means that recording matches server-side without the agent + * having to hand-pass any task id. Only run-token callers register an attempt. + */ +export async function registerEnvironmentVerificationForTaskRun( + auth: McpAuth, + environmentId: string, +): Promise { + const runId = extractRunId(auth); + + if (!runId) { + return; + } + + const taskRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, runId), + columns: { taskId: true }, + }); + + if (!taskRun?.taskId) { + return; + } + + await beginEnvironmentVerification(db, { + environmentId, + verificationTaskId: taskRun.taskId, + }); +} + /** * POST /api/mcp/environments * @@ -275,6 +309,10 @@ 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. + isVerified: false, + verificationError: null, }) .returning({ id: environments.id }); @@ -308,6 +346,7 @@ export async function createEnvironment( }); await attachEnvironmentIdToTaskRun(auth, created.id); + await registerEnvironmentVerificationForTaskRun(auth, created.id); return c.json({ success: true, 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..42630954 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, + registerEnvironmentVerificationForTaskRun, resolveEnvironmentWriteUserId, } from './createEnvironment'; @@ -155,10 +156,10 @@ export async function updateEnvironment( .map((name) => repoMap.get(name)) .filter((repositoryId): repositoryId is string => Boolean(repositoryId)); - await db.transaction(async (tx) => { + const { verificationCleared } = await db.transaction(async (tx) => { const now = new Date(); - await updateEnvironmentDefinition(tx, { + return updateEnvironmentDefinition(tx, { environmentId: id, fields: { name: config.name, @@ -179,6 +180,13 @@ export async function updateEnvironment( await attachEnvironmentIdToTaskRun(auth, id); + // A runtime-affecting update resets verification; register the calling + // setup task as the current verification attempt so its follow-up + // record_verification matches server-side. + if (verificationCleared) { + await registerEnvironmentVerificationForTaskRun(auth, id); + } + return c.json({ success: true, environmentId: id, 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..5b248aa3 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.client.test.tsx @@ -31,25 +31,45 @@ 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) => { + void asChild; + return ( + + ); + }, })); import { SetupOnboardingAgentWidget } from './SetupOnboardingAgentWidget'; diff --git a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx index 992966b2..9095bcaf 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupOnboardingAgentWidget.tsx @@ -7,8 +7,11 @@ 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 { @@ -73,11 +76,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 +95,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 +123,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; @@ -242,7 +276,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.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/components/settings/environments/EnvironmentDefinitionAgentTask.tsx b/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx index b56b0dec..b44b4111 100644 --- a/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentDefinitionAgentTask.tsx @@ -158,8 +158,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 +217,7 @@ export function useEnvironmentDefinitionAgentState({ refetchEnvironment, refetchLinkedEnvironment, succeeded, + verificationPending, ]); return { @@ -201,6 +227,11 @@ export function useEnvironmentDefinitionAgentState({ taskIsActive, matchingEnvironment, updatedEnvironment, + verificationSucceeded, + verificationFailed, + verificationPending, + verificationTaskId, + verificationError, }; } 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..ee123884 --- /dev/null +++ b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx @@ -0,0 +1,181 @@ +'use client'; + +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +import type { EnvironmentWithMeta } from '@/trpc/commands/environments'; +import { useRetryEnvironmentVerification } from '@/hooks/environments'; +import { + AlertCircle, + Badge, + BasicTooltip, + Button, + CheckCircle2, + Loader2, + RefreshCw, +} from '@/components/system'; + +type EnvironmentVerificationState = + | 'verified' + | 'in_progress' + | 'failed' + | 'configured'; + +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'; +} + +function VerificationBadge({ env }: { env: EnvironmentWithMeta }) { + const state = getEnvironmentVerificationState(env); + + switch (state) { + case 'verified': + return ( + +

Your environment is verified and ready to use.

+ {env.verifiedAt ? ( +

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

+ ) : null} + + } + > + + + Verified + +
+ ); + case 'in_progress': + return ( + + Roomote is checking that this environment works. You can keep + using it while verification finishes. + + } + > + + + Verification in progress + + + ); + case 'failed': + return ( + + Roomote could not verify that this environment works. It is still + usable; retry verification after checking the configuration. + + } + > + + + Verification failed + + + ); + case 'configured': + default: + return ( + + This environment is configured but has not been verified for its + current configuration. + + } + > + + Configured + + + ); + } +} + +/** + * Renders the verification status for an environment, its latest failure + * message when present, a link to the related verification task, and a Retry + * verification action. + */ +export function EnvironmentVerificationStatus({ + env, +}: { + env: EnvironmentWithMeta; +}) { + const retryVerification = useRetryEnvironmentVerification(); + const state = getEnvironmentVerificationState(env); + const isRetrying = + retryVerification.isPending && + retryVerification.variables?.environmentId === env.id; + const inProgress = state === 'in_progress'; + + return ( +
+
+ Verification · + + {env.verificationTaskId ? ( + + View task + + ) : null} + {!inProgress ? ( + + ) : null} +
+ {state === 'failed' && env.verificationError ? ( +

+ {env.verificationError} +

+ ) : 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..e54b55bb 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', + 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', () => ({ @@ -304,6 +314,7 @@ vi.mock('@/components/system', () => ({ Loader2: Icon, Pencil: Icon, Plus: Icon, + RefreshCw: Icon, Popover: ({ children }: { children: ReactNode }) =>
{children}
, PopoverContent: ({ children, @@ -381,6 +392,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(); diff --git a/apps/web/src/components/settings/environments/Environments.tsx b/apps/web/src/components/settings/environments/Environments.tsx index f730c859..843faf1b 100644 --- a/apps/web/src/components/settings/environments/Environments.tsx +++ b/apps/web/src/components/settings/environments/Environments.tsx @@ -49,6 +49,7 @@ import { Section } from '@/components/settings'; import { DuplicateEnvironmentDialog } from './DuplicateEnvironmentDialog'; import { SnapshotStatusBadge } from './SnapshotStatusBadge'; +import { EnvironmentVerificationStatus } from './EnvironmentVerificationStatus'; function getEnvironmentSnapshot( environment: EnvironmentWithMeta, @@ -250,6 +251,8 @@ export function Environments() { ))} + +
{visibleSnapshotProviders.map((provider) => { diff --git a/apps/web/src/hooks/environments/index.ts b/apps/web/src/hooks/environments/index.ts index 8f77c86a..82d38722 100644 --- a/apps/web/src/hooks/environments/index.ts +++ b/apps/web/src/hooks/environments/index.ts @@ -4,5 +4,6 @@ export { useUpdateEnvironment, useDeleteEnvironment, useDuplicateEnvironment, + useRetryEnvironmentVerification, } from './useEnvironmentMutations'; export { useValidateEnvironmentConfig } from './useValidateEnvironmentConfig'; diff --git a/apps/web/src/hooks/environments/useEnvironmentMutations.ts b/apps/web/src/hooks/environments/useEnvironmentMutations.ts index 51f4fd43..c29761be 100644 --- a/apps/web/src/hooks/environments/useEnvironmentMutations.ts +++ b/apps/web/src/hooks/environments/useEnvironmentMutations.ts @@ -112,3 +112,31 @@ export function useDuplicateEnvironment() { }), ); } + +export function useRetryEnvironmentVerification() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.environments.retryVerification.mutationOptions({ + onSuccess: (_result, variables) => { + queryClient.invalidateQueries({ + queryKey: trpc.environments.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.environments.byId.queryKey({ + id: variables.environmentId, + }), + }); + toast.success('Environment verification started'); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : 'Failed to start environment verification', + ); + }, + }), + ); +} diff --git a/apps/web/src/hooks/environments/useEnvironments.ts b/apps/web/src/hooks/environments/useEnvironments.ts index 37daeadf..ea4a6697 100644 --- a/apps/web/src/hooks/environments/useEnvironments.ts +++ b/apps/web/src/hooks/environments/useEnvironments.ts @@ -14,10 +14,10 @@ export function useEnvironments({ enabled?: boolean; } = {}) { const trpc = useTRPC(); - const [isSnapshotting, setIsSnapshotting] = useState(false); + const [hasActiveWork, setHasActiveWork] = useState(false); const { refetchInterval } = useRealtimePolling({ - enabled: poll && isSnapshotting, + enabled: poll && hasActiveWork, interval: 3000, }); @@ -30,12 +30,21 @@ export function useEnvironments({ useEffect(() => { if (!poll || !enabled) return; - setIsSnapshotting( - query.data?.some(({ snapshots }) => - Object.values(snapshots).some( + // Keep polling while a snapshot is being created or an environment's + // verification is still in progress, so status badges update on their own. + setHasActiveWork( + query.data?.some((environment) => { + const isSnapshotting = Object.values(environment.snapshots).some( (snapshot) => snapshot?.snapshotStatus === 'pending', - ), - ) ?? false, + ); + const verificationInProgress = + !environment.isVerified && + !environment.verificationError && + !!environment.verificationTaskId && + environment.verificationTaskActive; + + return isSnapshotting || verificationInProgress; + }) ?? false, ); }, [enabled, poll, query.data]); diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index 9123aa40..ae5f0f5c 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -4,6 +4,7 @@ const { mockEnqueueTask, mockGetBranches, mockGetRepositories, + mockBeginEnvironmentVerification, } = vi.hoisted(() => ({ mockCheckRepoAccess: vi.fn(), mockDbSelect: vi.fn(), @@ -24,6 +25,7 @@ const { installationId: 'installation-1', }, ]), + mockBeginEnvironmentVerification: vi.fn(), })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -37,6 +39,7 @@ vi.mock('@roomote/github', () => ({ vi.mock('@roomote/db/server', () => ({ activeRunStatuses: [], and: vi.fn(), + beginEnvironmentVerification: mockBeginEnvironmentVerification, cancelTaskRunDirect: vi.fn(), createEnvironmentConfigVersionSnapshot: vi.fn(), db: { @@ -68,6 +71,7 @@ import { TaskPayloadKind } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; import { createEnvironmentCommand, + retryEnvironmentVerificationCommand, startEnvironmentDefinitionTaskCommand, updateEnvironmentCommand, validateConfigCommand, @@ -136,6 +140,101 @@ describe('startEnvironmentDefinitionTaskCommand', () => { }); }); +describe('retryEnvironmentVerificationCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEnqueueTask.mockResolvedValue({ + taskId: 'task-verify-1', + id: 'run-verify-1', + }); + }); + + it('enqueues a verification task and registers the new attempt', async () => { + mockDbSelect + // Environment lookup. + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [{ id: 'env-1', name: 'My Env' }], + }), + }), + }) + // Active verification task lookup (none active). + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + orderBy: () => ({ limit: async () => [] }), + }), + }), + }); + + const result = await retryEnvironmentVerificationCommand(buildMockAuth(), { + environmentId: 'env-1', + }); + + expect(result).toEqual({ taskId: 'task-verify-1' }); + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: TaskPayloadKind.StandardTask, + payload: expect.objectContaining({ + environmentId: 'env-1', + verifiesEnvironmentId: 'env-1', + }), + }), + workflow: 'standard', + }), + ); + expect(mockBeginEnvironmentVerification).toHaveBeenCalledWith( + expect.anything(), + { environmentId: 'env-1', verificationTaskId: 'task-verify-1' }, + ); + }); + + it('rejects when a verification task is already active', async () => { + mockDbSelect + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [{ id: 'env-1', name: 'My Env' }], + }), + }), + }) + .mockReturnValueOnce({ + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => [{ taskId: 'task-active' }], + }), + }), + }), + }); + + await expect( + retryEnvironmentVerificationCommand(buildMockAuth(), { + environmentId: 'env-1', + }), + ).rejects.toThrow('already being verified'); + + expect(mockEnqueueTask).not.toHaveBeenCalled(); + expect(mockBeginEnvironmentVerification).not.toHaveBeenCalled(); + }); + + it('rejects when the environment does not exist', async () => { + mockDbSelect.mockReturnValueOnce({ + from: () => ({ + where: () => ({ limit: async () => [] }), + }), + }); + + await expect( + retryEnvironmentVerificationCommand(buildMockAuth(), { + environmentId: 'env-missing', + }), + ).rejects.toThrow('Environment not found'); + }); +}); + describe('environment repository validation', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 8558a196..ecf37955 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -8,6 +8,7 @@ import { taskRuns, tasks, and, + beginEnvironmentVerification, desc, eq, inArray, @@ -23,11 +24,13 @@ import { } from '@roomote/db/server'; import { activeRunStatuses, + ALL_REPOSITORIES, RunStatus, TaskPayloadKind, appendEnvironmentDefinitionGuidance, buildCreateEnvironmentDefinitionPrompt, buildEnvironmentDefinitionWorkspacePayload, + buildEnvironmentVerificationPrompt, type ComputeProvider, type EnvironmentConfig, environmentConfigSchema, @@ -73,6 +76,19 @@ export interface EnvironmentWithMeta { createdAt: Date; updatedAt: Date; snapshots: Partial>; + /** + * Verification state. `isVerified` is true once a verification task reported + * success for the current configuration. `verificationTaskId` links to the + * Roomote task that most recently ran (or is running) verification. + * `verificationTaskActive` is true only while that task still has an active + * run, so the UI can distinguish "verification in progress" from a stale + * task id left by a crashed or unreported attempt. + */ + isVerified: boolean; + verificationTaskId: string | null; + verificationTaskActive: boolean; + verifiedAt: Date | null; + verificationError: string | null; } export interface EnvironmentConfigVersionDetail { @@ -202,6 +218,7 @@ function toEnvironmentWithMeta( env: typeof environments.$inferSelect, snapshots: Partial>, repositoryMappings: Array<{ repositoryId: string }>, + activeVerificationTaskIds: Set, ): EnvironmentWithMeta { return { id: env.id, @@ -215,9 +232,49 @@ function toEnvironmentWithMeta( createdAt: env.createdAt, updatedAt: env.updatedAt, snapshots, + isVerified: env.isVerified, + verificationTaskId: env.verificationTaskId, + verificationTaskActive: + env.verificationTaskId !== null && + activeVerificationTaskIds.has(env.verificationTaskId), + verifiedAt: env.verifiedAt, + verificationError: env.verificationError, }; } +/** + * Given a set of environments, return the subset of their `verificationTaskId` + * values that still have an active task run. Used to distinguish an in-progress + * verification from a stale task id left by a crashed or unreported attempt. + */ +async function getActiveVerificationTaskIds( + envs: Array<{ verificationTaskId: string | null }>, +): Promise> { + const taskIds = [ + ...new Set( + envs + .map((env) => env.verificationTaskId) + .filter((taskId): taskId is string => taskId !== null), + ), + ]; + + if (taskIds.length === 0) { + return new Set(); + } + + const activeRuns = await db + .selectDistinct({ taskId: taskRuns.taskId }) + .from(taskRuns) + .where( + and( + inArray(taskRuns.taskId, taskIds), + inArray(taskRuns.status, [...activeRunStatuses]), + ), + ); + + return new Set(activeRuns.map((run) => run.taskId)); +} + async function getRepositoryMappingsByEnvironmentId(environmentIds: string[]) { if (environmentIds.length === 0) { return new Map>(); @@ -266,12 +323,14 @@ export async function getEnvironmentsCommand( await getRepositoryMappingsByEnvironmentId( envs.map((environment) => environment.id), ); + const activeVerificationTaskIds = await getActiveVerificationTaskIds(envs); return envs.map((env) => toEnvironmentWithMeta( env, snapshotsByEnvironment.get(env.id) ?? {}, repositoryMappingsByEnvironmentId.get(env.id) ?? [], + activeVerificationTaskIds, ), ); } @@ -329,11 +388,13 @@ export async function getEnvironmentByIdCommand( const snapshotsByEnvironment = await loadEnvironmentSnapshots([env]); const repositoryMappingsByEnvironmentId = await getRepositoryMappingsByEnvironmentId([env.id]); + const activeVerificationTaskIds = await getActiveVerificationTaskIds([env]); return toEnvironmentWithMeta( env, snapshotsByEnvironment.get(env.id) ?? {}, repositoryMappingsByEnvironmentId.get(env.id) ?? [], + activeVerificationTaskIds, ); } @@ -402,6 +463,9 @@ export async function createEnvironmentCommand( description: input.description, config: parseResult.data, createdByUserId: userId, + // New environments start configured but not yet verified. + isVerified: false, + verificationError: null, }) .returning({ id: environments.id }); @@ -777,6 +841,97 @@ export async function startEnvironmentDefinitionTaskCommand( }; } +/** + * Find an active verification task for the given environment, if any. A + * verification task carries the `verifiesEnvironmentId` marker in its payload. + */ +async function getActiveVerificationTaskId( + environmentId: string, +): Promise { + const [job] = await db + .select({ taskId: taskRuns.taskId }) + .from(taskRuns) + .where( + and( + inArray(taskRuns.status, [...activeRunStatuses]), + sql`${taskRuns.payload} ->> 'verifiesEnvironmentId' = ${environmentId}`, + ), + ) + .orderBy(desc(taskRuns.createdAt), desc(taskRuns.id)) + .limit(1); + + return job?.taskId ?? null; +} + +/** + * Re-run environment verification: enqueue a standard task that runs inside the + * target environment, verifies it, and records the result through the + * `record_verification` MCP action. Resets the environment to the unverified + * state and stores the new verification task id. + */ +export async function retryEnvironmentVerificationCommand( + auth: UserAuthSuccess, + input: { environmentId: string }, +): Promise<{ taskId: string }> { + assertAdmin(auth); + + const { userId } = auth; + + const [environment] = await db + .select({ + id: environments.id, + name: environments.name, + }) + .from(environments) + .where( + and(eq(environments.id, input.environmentId), buildOwnershipFilter()), + ) + .limit(1); + + if (!environment) { + throw new Error('Environment not found'); + } + + const activeVerificationTaskId = await getActiveVerificationTaskId( + environment.id, + ); + + if (activeVerificationTaskId) { + throw new Error('This environment is already being verified.'); + } + + const prompt = buildEnvironmentVerificationPrompt({ + environmentId: environment.id, + environmentName: environment.name, + }); + + const launchResult = await enqueueTask({ + title: `Verify environment: ${environment.name}`, + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: ALL_REPOSITORIES, + environmentId: environment.id, + verifiesEnvironmentId: environment.id, + description: prompt, + }, + }, + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + // Reset verification state and point the environment at the new task so + // record_verification can only be applied by this attempt. + await beginEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: launchResult.taskId, + }); + + return { taskId: launchResult.taskId }; +} + export async function cancelEnvironmentDefinitionTaskCommand( auth: UserAuthSuccess, input: { taskId: string }, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index a178e27b..8517608a 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -138,6 +138,7 @@ import { updateEnvironmentCommand, startEnvironmentDefinitionTaskCommand, cancelEnvironmentDefinitionTaskCommand, + retryEnvironmentVerificationCommand, deleteEnvironmentCommand, duplicateEnvironmentCommand, validateConfigCommand, @@ -1053,6 +1054,12 @@ export const appRouter = createRouter({ cancelEnvironmentDefinitionTaskCommand(auth, input), ), + retryVerification: protectedProcedure + .input(z.object({ environmentId: z.string().uuid() })) + .mutation(({ ctx: { auth }, input }) => + retryEnvironmentVerificationCommand(auth, input), + ), + delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/create-environment.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/create-environment.test.ts index 9d0edb01..32d6c776 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/create-environment.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/create-environment.test.ts @@ -1,5 +1,6 @@ import { handleCreateEnvironment, + handleRecordVerification, handleUpdateEnvironment, } from '../create-environment.js'; import * as tasksApiClient from '../tasks-api-client.js'; @@ -180,3 +181,84 @@ repositories: expect(tasksApiClient.updateEnvironment).not.toHaveBeenCalled(); }); }); + +describe('handleRecordVerification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('records a successful verification', async () => { + vi.mocked( + tasksApiClient.recordEnvironmentVerification, + ).mockResolvedValueOnce({ + success: true, + environmentId: 'env-1', + isVerified: true, + }); + + const result = await handleRecordVerification( + { environmentId: 'env-1', success: true }, + config, + ); + + const parsed = JSON.parse(result.content[0]?.text ?? ''); + + expect(parsed.isVerified).toBe(true); + expect(parsed.message).toBe( + 'Environment verification recorded as successful.', + ); + expect(tasksApiClient.recordEnvironmentVerification).toHaveBeenCalledWith( + config, + { + environmentId: 'env-1', + success: true, + error: undefined, + }, + ); + }); + + it('records a failed verification with an error message', async () => { + vi.mocked( + tasksApiClient.recordEnvironmentVerification, + ).mockResolvedValueOnce({ + success: true, + environmentId: 'env-1', + isVerified: false, + }); + + const result = await handleRecordVerification( + { environmentId: 'env-1', success: false, error: 'boot failed' }, + config, + ); + + const parsed = JSON.parse(result.content[0]?.text ?? ''); + + expect(parsed.isVerified).toBe(false); + expect(parsed.message).toBe('Environment verification recorded as failed.'); + expect(tasksApiClient.recordEnvironmentVerification).toHaveBeenCalledWith( + config, + { + environmentId: 'env-1', + success: false, + error: 'boot failed', + }, + ); + }); + + it('requires an environment id', async () => { + const result = await handleRecordVerification( + { environmentId: ' ', success: true }, + config, + ); + + const parsed = JSON.parse(result.content[0]?.text ?? ''); + + expect(parsed.success).toBe(false); + expect(parsed.error).toBe( + 'environmentId is required for record_verification', + ); + expect(tasksApiClient.recordEnvironmentVerification).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 64a99f97..a3738337 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -677,14 +677,19 @@ describe('roomote MCP tool descriptions', () => { it('serializes manage_environments definition as a plain string schema, not a union', async () => { const { registeredTools } = await importRoomoteMcpServer(); const envTool = getRegisteredTool(registeredTools, 'manage_environments'); - const definitionSchema = envTool.config.inputSchema + const definitionField = envTool.config.inputSchema .definition as unknown as z.ZodType; + // `definition` is optional (not required for the record_verification + // action), so unwrap the optional before asserting the inner string schema. + const definitionSchema = + definitionField instanceof z.ZodOptional + ? (definitionField.unwrap() as z.ZodType) + : definitionField; + expect(definitionSchema).toBeInstanceOf(z.ZodString); expect(definitionSchema).not.toBeInstanceOf(z.ZodUnion); - expect((definitionSchema as z.ZodString).description).toContain( - 'YAML or JSON string', - ); + expect(definitionField.description).toContain('YAML or JSON string'); }); it('documents hidden investigation context on task suggestions', () => { diff --git a/apps/worker/src/mcp/roomote-mcp-server/create-environment.ts b/apps/worker/src/mcp/roomote-mcp-server/create-environment.ts index 27603e0d..a6d50d02 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/create-environment.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/create-environment.ts @@ -5,7 +5,11 @@ import { environmentConfigSchema, } from '@roomote/types'; -import { createEnvironment, updateEnvironment } from './tasks-api-client.js'; +import { + createEnvironment, + recordEnvironmentVerification, + updateEnvironment, +} from './tasks-api-client.js'; import { catchError, errorResult, successResult } from './tool-result.js'; import type { RoomoteConfig, ToolResult } from './types.js'; @@ -205,3 +209,35 @@ export async function handleUpdateEnvironment( return catchError(error); } } + +export async function handleRecordVerification( + params: { + environmentId: string; + success: boolean; + error?: string; + }, + config: RoomoteConfig, +): Promise { + try { + const environmentId = params.environmentId.trim(); + if (!environmentId) { + return errorResult('environmentId is required for record_verification'); + } + + const result = await recordEnvironmentVerification(config, { + environmentId, + success: params.success, + error: params.error, + }); + + return successResult({ + environmentId: result.environmentId, + isVerified: result.isVerified, + message: result.isVerified + ? 'Environment verification recorded as successful.' + : 'Environment verification recorded as failed.', + }); + } catch (error) { + return catchError(error); + } +} diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index faa68335..b6751c02 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -37,6 +37,7 @@ import { handleSendMessage } from './send-message.js'; import { handleListEnvironments } from './list-environments.js'; import { handleCreateEnvironment, + handleRecordVerification, handleUpdateEnvironment, } from './create-environment.js'; import { handleRequestEnvironmentVariables } from './request-environment-variables.js'; @@ -763,21 +764,22 @@ roomoteMcpServer.registerTool( 'manage_environments', { title: 'Manage Environments', - description: `Create or update ${PRODUCT_NAME} environments.`, + description: `Create or update ${PRODUCT_NAME} environments, or record an environment verification result.`, inputSchema: { action: z - .enum(['create', 'update']) + .enum(['create', 'update', 'record_verification']) .describe('The environment action to perform'), definition: z .string() + .optional() .describe( - 'Environment definition as a YAML or JSON string. Must satisfy EnvironmentConfig (e.g., include name and repositories).', + 'Environment definition as a YAML or JSON string. Must satisfy EnvironmentConfig (e.g., include name and repositories). Required for "create" and "update".', ), environmentId: z .string() .optional() .describe( - 'Existing environment ID to update. Required for action "update".', + 'Existing environment ID. Required for "update" and "record_verification".', ), format: z .enum(['auto', 'json', 'yaml']) @@ -795,6 +797,18 @@ roomoteMcpServer.registerTool( .describe( 'Optional description override applied after parsing definition.', ), + success: z + .boolean() + .optional() + .describe( + 'For "record_verification": whether the environment verification succeeded.', + ), + error: z + .string() + .optional() + .describe( + 'For "record_verification" with success=false: a short, user-safe failure message. Never include secrets or full environment YAML.', + ), }, annotations: { readOnlyHint: false, @@ -809,6 +823,29 @@ roomoteMcpServer.registerTool( return errorResult('ROOMOTE_CLOUD_TOKEN environment variable not set'); } + if (params.action === 'record_verification') { + if (typeof params.success !== 'boolean') { + return errorResult( + 'success (boolean) is required for record_verification', + ); + } + + return handleRecordVerification( + { + environmentId: params.environmentId ?? '', + success: params.success, + error: params.error, + }, + config, + ); + } + + if (params.definition === undefined) { + return errorResult( + `definition is required for action "${params.action}"`, + ); + } + if (params.action === 'update') { return handleUpdateEnvironment( { diff --git a/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts b/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts index be7c00df..4b2930e2 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts @@ -25,6 +25,7 @@ import type { ListEnvironmentsResponse, CreateEnvironmentResponse, UpdateEnvironmentResponse, + RecordVerificationResponse, SubmitAutomationWorkItemsResponse, SubmitTaskSuggestionsResponse, SourceControlPullRequestReadResponse, @@ -478,3 +479,29 @@ export async function updateEnvironment( 'Failed to update environment', ); } + +/** + * Record the terminal result of an environment verification task. + */ +export async function recordEnvironmentVerification( + config: RoomoteConfig, + params: { + environmentId: string; + success: boolean; + error?: string; + }, +): Promise { + return apiFetch( + config, + `/api/mcp/environments/${encodeURIComponent(params.environmentId)}/verification`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + success: params.success, + ...(params.error !== undefined ? { error: params.error } : {}), + }), + }, + 'Failed to record environment verification', + ); +} diff --git a/apps/worker/src/mcp/roomote-mcp-server/types.ts b/apps/worker/src/mcp/roomote-mcp-server/types.ts index 0d12465b..997f91b0 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/types.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/types.ts @@ -110,6 +110,12 @@ export interface UpdateEnvironmentResponse { name: string; } +export interface RecordVerificationResponse { + success: boolean; + environmentId: string; + isVerified: boolean; +} + export interface SlackThreadReplyResponse { messageTs: string; } diff --git a/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md b/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md index 5f8c2004..1bb8b7b9 100644 --- a/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md +++ b/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md @@ -143,6 +143,8 @@ You are an expert Roomote environment analyst. Analyze the already-checked-out r 32. If the monitored summary reaches `Ready`, `Idle`, or `Needs input`, do not keep polling that same state indefinitely. Inspect the latest task messages to determine whether the verification task already reported success, surfaced a blocker, or is unexpectedly waiting for follow-up input. 33. If those latest task messages clearly report that the environment looks ready, treat that as a successful spawned-task run and report the observed success directly. 34. If those latest task messages surface a startup or runtime blocker, request unexpected user input, or otherwise fail to give a clear success outcome, treat that as a blocker or verification failure instead of pretending the environment is verified. +34a. The persisted environment metadata is the source of truth for the web UI's verification state, so you must record the verification outcome explicitly. Once you have a clear outcome, call the Roomote MCP tool `mcp__roomote__manage_environments` with `action: "record_verification"` and `environmentId` set to the created or updated environment id: use `success: true` only when the explicit "environment looks ready" criterion above is satisfied, and use `success: false` with a short, user-safe `error` message when verification reaches a terminal failure or cannot be completed. Never put secrets or the full environment YAML in the `error` text. You do not need to pass any task id; Roomote derives the recording task automatically. +34b. If `record_verification` is rejected because a newer verification attempt or a runtime-affecting edit superseded this one, do not retry recording; treat this verification attempt as superseded and stop, since a newer attempt now owns the environment's verification state. 35. If the settle notification or the monitored summary reports `Failed`, `Canceled`, a startup or runtime error, or an `Environment Setup` state of `failed` or `completed with warnings`, inspect the exact status and error — asking the verification task for the failing command names, exit codes, and log excerpts from `.roomote/setup-status.json` and `.roomote/setup-logs/` when they are not already surfaced — and decide whether the problem appears fixable within environment-setup scope, such as revising commands, services, environment variables, startup order, readiness checks, or other environment-definition details. 36. When the spawned verification task reveals a fixable setup or environment-definition error, try to fix it yourself, rerun any affected local validation, recreate or update the environment with the revised YAML, launch a fresh verification task, and repeat the monitoring process instead of stopping after the first failure. 37. Treat each fix attempt as a real retry loop: revise based on the observed error, revalidate the affected setup steps, persist the updated environment again, relaunch the verification task with `notifyOnSettle: true`, and wait for the new settle notification rather than assuming the old failure is resolved. @@ -194,6 +196,7 @@ You are an expert Roomote environment analyst. Analyze the already-checked-out r The final environment definition is best-effort and should be runnable once required environment variables are provided. Environment creation or update is attempted only after local install/test/start validation is successful enough to proceed. When environment persistence succeeds, a lightweight Roomote verification task is launched against that environment and monitored by calling the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "get_summary"` before this setup task finishes, unless an explicit blocker prevents launch or monitoring. +The verification outcome is recorded through `mcp__roomote__manage_environments` with `action: "record_verification"` (`success: true` only when the environment explicitly looks ready, otherwise `success: false` with a user-safe error), unless a blocker prevents launch or monitoring or the recording is rejected as superseded. The final response summarizes the environment name, whether it was created or updated, key validation outcomes, and any blockers - without including the raw YAML config or internal orchestration details. A successful onboarding final response ends by telling the user that the environment is ready and linking `[Create a new task](/)` as the clear next action. If the follow-up verification task reaches `Completed` without a surfaced startup or runtime failure, the final response reports that the environment is ready to use instead of asking the user to confirm it manually or describing the verification task. @@ -336,6 +339,8 @@ You are an expert Roomote environment analyst. Analyze the already-checked-out r While the follow-up check runs, narrate concise progress updates in plain language instead of silently waiting or pushing the waiting back onto the user. Do not mention a spawned task, task status, polling, or monitoring in those user-facing updates. Preparing the environment can take 5 minutes or more. Do not stop monitoring solely because the verification task is taking a long time to start; keep checking until it reaches a terminal state or surfaces a blocker you can report or act on. If the monitored summary settles into `Ready`, `Idle`, or `Needs input`, inspect the latest task messages instead of polling that state forever, and only treat it as success when those messages clearly report that the environment looks ready. +Record the verification outcome explicitly through the Roomote MCP tool `mcp__roomote__manage_environments` with `action: "record_verification"` and the created or updated `environmentId`: `success: true` only when the explicit "environment looks ready" criterion is satisfied, and `success: false` with a short, user-safe `error` when verification reaches a terminal failure or cannot be completed. Do not include secrets or the full environment YAML in the `error`, and do not pass a task id (Roomote derives the recording task from the current run). +Treat a rejected `record_verification` result as a superseded attempt: do not retry recording, because a newer verification attempt or a runtime-affecting edit now owns the environment's verification state. If the spawned verification task surfaces a fixable setup or environment-definition problem, attempt to fix it yourself, recreate or update the environment, launch a fresh verification task, and repeat the monitoring loop instead of stopping after the first discovered error. Bound that environment-repair loop to at most 2 additional full retries after the first spawned verification task unless the task context clearly justifies fewer attempts. Do not claim automatic repair for failures that actually require product/source changes outside environment-setup scope, unsupported infrastructure, missing external credentials, or a user decision you cannot safely infer; report those as blockers. diff --git a/packages/db/drizzle/0011_flimsy_sally_floyd.sql b/packages/db/drizzle/0011_flimsy_sally_floyd.sql new file mode 100644 index 00000000..9096d932 --- /dev/null +++ b/packages/db/drizzle/0011_flimsy_sally_floyd.sql @@ -0,0 +1,4 @@ +ALTER TABLE "environments" ADD COLUMN "is_verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "environments" ADD COLUMN "verification_task_id" text;--> statement-breakpoint +ALTER TABLE "environments" ADD COLUMN "verified_at" timestamp;--> statement-breakpoint +ALTER TABLE "environments" ADD COLUMN "verification_error" text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..273572c4 --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,9043 @@ +{ + "id": "79ebce54-6a94-40b5-8ebc-765b1870fab7", + "prevId": "05a8029f-e2aa-4192-a6b5-32234bc1a149", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_idx": { + "name": "auth_accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_accounts_provider_account_unique": { + "name": "auth_accounts_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_users_created_at_idx": { + "name": "auth_users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_cursor": { + "name": "scan_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage": { + "name": "compute_provider_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_action": { + "name": "lifecycle_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measurement_source": { + "name": "measurement_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_clock_duration_ms": { + "name": "wall_clock_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active_cpu_duration_ms": { + "name": "active_cpu_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "observed_memory_mib_milliseconds": { + "name": "observed_memory_mib_milliseconds", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_ingress_bytes": { + "name": "network_ingress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_egress_bytes": { + "name": "network_egress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "compute_provider_usage_provider_usage_id_unique": { + "name": "compute_provider_usage_provider_usage_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_run_id_idx": { + "name": "compute_provider_usage_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_task_id_idx": { + "name": "compute_provider_usage_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_created_at_idx": { + "name": "compute_provider_usage_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compute_provider_usage_task_id_tasks_id_fk": { + "name": "compute_provider_usage_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage_samples": { + "name": "compute_provider_usage_samples", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sampled_at": { + "name": "sampled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cpu_usage_ns_total": { + "name": "cpu_usage_ns_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_usage_bytes": { + "name": "memory_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_peak_usage_bytes": { + "name": "memory_peak_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compute_provider_usage_samples_provider_usage_sampled_at_unique": { + "name": "compute_provider_usage_samples_provider_usage_sampled_at_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sampled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_run_id_idx": { + "name": "compute_provider_usage_samples_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_task_id_idx": { + "name": "compute_provider_usage_samples_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_created_at_idx": { + "name": "compute_provider_usage_samples_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_samples_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_samples_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compute_provider_usage_samples_task_id_tasks_id_fk": { + "name": "compute_provider_usage_samples_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_mcp_enablements": { + "name": "deployment_mcp_enablements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_mcp_enablements_enabled_by_user_id_users_id_fk": { + "name": "deployment_mcp_enablements_enabled_by_user_id_users_id_fk", + "tableFrom": "deployment_mcp_enablements", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_mcp_enablements_mcp_unique": { + "name": "deployment_mcp_enablements_mcp_unique", + "nullsNotDistinct": false, + "columns": ["mcp_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_secrets": { + "name": "deployment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_secrets_name_unique": { + "name": "deployment_secrets_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_model_settings": { + "name": "task_model_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_slack_channel_id": { + "name": "router_debug_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_model_config": { + "name": "runtime_model_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runtime_compute_config": { + "name": "runtime_compute_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_policy": { + "name": "access_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "license_key": { + "name": "license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_analytics_id": { + "name": "instance_analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_known_version": { + "name": "latest_known_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version_checked_at": { + "name": "latest_version_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_new_state": { + "name": "setup_new_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slack_onboarding_stage": { + "name": "slack_onboarding_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_slack_channel_id": { + "name": "manager_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorship_instructions": { + "name": "authorship_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_authorship_rules": { + "name": "compiled_authorship_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_issues": { + "name": "compiled_authorship_issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_at": { + "name": "compiled_authorship_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "style_guidance": { + "name": "style_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_summon_emoji": { + "name": "slack_summon_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_ack_emoji": { + "name": "slack_ack_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eyes'" + }, + "slack_completion_emoji": { + "name": "slack_completion_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'white_check_mark'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_config_versions": { + "name": "environment_config_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_config_versions_environment_id_idx": { + "name": "environment_config_versions_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_config_versions_environment_version_unique": { + "name": "environment_config_versions_environment_version_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_config_versions_environment_id_environments_id_fk": { + "name": "environment_config_versions_environment_id_environments_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_config_versions_created_by_user_id_users_id_fk": { + "name": "environment_config_versions_created_by_user_id_users_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repository_mappings": { + "name": "environment_repository_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "env_repo_mappings_env_id_idx": { + "name": "env_repo_mappings_env_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "env_repo_mappings_repo_id_idx": { + "name": "env_repo_mappings_repo_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repository_mappings_environment_id_environments_id_fk": { + "name": "environment_repository_mappings_environment_id_environments_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repository_mappings_repository_id_repositories_id_fk": { + "name": "environment_repository_mappings_repository_id_repositories_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "env_repo_mappings_unique": { + "name": "env_repo_mappings_unique", + "nullsNotDistinct": false, + "columns": ["environment_id", "repository_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_snapshots": { + "name": "environment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_snapshots_environment_id_idx": { + "name": "environment_snapshots_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_snapshots_env_provider_unique": { + "name": "environment_snapshots_env_provider_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_snapshots\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_snapshots_environment_id_environments_id_fk": { + "name": "environment_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_snapshots", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_updated_by_user_id": { + "name": "last_updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_name_unique": { + "name": "environment_variables_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_users_id_fk": { + "name": "environment_variables_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_created_by_user_id_users_id_fk": { + "name": "environment_variables_created_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "environment_variables_last_updated_by_user_id_users_id_fk": { + "name": "environment_variables_last_updated_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["last_updated_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_eval": { + "name": "is_eval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declarative_source": { + "name": "declarative_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "verification_task_id": { + "name": "verification_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_error": { + "name": "verification_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_user_id_idx": { + "name": "environments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_created_by_user_id_idx": { + "name": "environments_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_snapshot_expires_at_idx": { + "name": "environments_snapshot_expires_at_idx", + "columns": [ + { + "expression": "snapshot_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_user_id_users_id_fk": { + "name": "environments_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members_count": { + "name": "members_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_deployment_installation_unique": { + "name": "github_installations_deployment_installation_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_installed_by_user_id_users_id_fk": { + "name": "github_installations_installed_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pending_installations": { + "name": "github_pending_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pending_installations_requested_by_user_id_idx": { + "name": "github_pending_installations_requested_by_user_id_idx", + "columns": [ + { + "expression": "requested_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pending_installations_user_id_users_id_fk": { + "name": "github_pending_installations_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pending_installations_requested_by_user_id_users_id_fk": { + "name": "github_pending_installations_requested_by_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_mappings": { + "name": "github_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_user_mappings_github_login_idx": { + "name": "github_user_mappings_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_user_mappings_user_id_idx": { + "name": "github_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_user_mappings_user_id_users_id_fk": { + "name": "github_user_mappings_user_id_users_id_fk", + "tableFrom": "github_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_mappings_unique": { + "name": "github_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["github_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_created_at_idx": { + "name": "invites_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_invited_by_user_id_users_id_fk": { + "name": "invites_invited_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_pending_selections": { + "name": "linear_pending_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_workspace'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "selected_repo": { + "name": "selected_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_options": { + "name": "workspace_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linear_pending_selections_expires_at_idx": { + "name": "linear_pending_selections_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linear_pending_selections_step_idx": { + "name": "linear_pending_selections_step_idx", + "columns": [ + { + "expression": "step", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_pending_selections_user_id_users_id_fk": { + "name": "linear_pending_selections_user_id_users_id_fk", + "tableFrom": "linear_pending_selections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "linear_pending_selections_session_id_unique": { + "name": "linear_pending_selections_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_status": { + "name": "auth_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_user_id_idx": { + "name": "mcp_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_role_idx": { + "name": "mcp_connections_role_idx", + "columns": [ + { + "expression": "mcp_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_user_id_users_id_fk": { + "name": "mcp_connections_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_connections_user_mcp_id_unique": { + "name": "mcp_connections_user_mcp_id_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "mcp_id", "connection_role"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_replays": { + "name": "mcp_oauth_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_replays_connection_id_idx": { + "name": "mcp_oauth_replays_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_user_id_idx": { + "name": "mcp_oauth_replays_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_expires_at_idx": { + "name": "mcp_oauth_replays_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_replays_connection_id_mcp_connections_id_fk": { + "name": "mcp_oauth_replays_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_replays_user_id_users_id_fk": { + "name": "mcp_oauth_replays_user_id_users_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_replays_token_unique": { + "name": "mcp_oauth_replays_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microsoft_auth_user_mappings": { + "name": "microsoft_auth_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_tenant_id": { + "name": "microsoft_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_aad_object_id": { + "name": "microsoft_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "microsoft_auth_user_mappings_user_id_idx": { + "name": "microsoft_auth_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_account_id_idx": { + "name": "microsoft_auth_user_mappings_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_auth_account_idx": { + "name": "microsoft_auth_user_mappings_auth_account_idx", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_aad_object_unique": { + "name": "microsoft_auth_user_mappings_aad_object_unique", + "columns": [ + { + "expression": "microsoft_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "microsoft_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "microsoft_auth_user_mappings_user_id_auth_users_id_fk": { + "name": "microsoft_auth_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_state": { + "name": "oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replay_token": { + "name": "replay_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_state_connection_id_idx": { + "name": "oauth_state_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_replay_token_idx": { + "name": "oauth_state_replay_token_idx", + "columns": [ + { + "expression": "replay_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_expires_at_idx": { + "name": "oauth_state_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_state_connection_id_mcp_connections_id_fk": { + "name": "oauth_state_connection_id_mcp_connections_id_fk", + "tableFrom": "oauth_state", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_request_facts": { + "name": "pull_request_facts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_pull_request_id": { + "name": "external_pull_request_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_remote": { + "name": "created_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at_remote": { + "name": "updated_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "closed_at_remote": { + "name": "closed_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_at_remote": { + "name": "merged_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_facts_deployment_repo_pr_unique": { + "name": "pull_request_facts_deployment_repo_pr_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_created_idx": { + "name": "pull_request_facts_deployment_created_idx", + "columns": [ + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_repo_created_idx": { + "name": "pull_request_facts_deployment_repo_created_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_state_created_idx": { + "name": "pull_request_facts_deployment_state_created_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_author_created_idx": { + "name": "pull_request_facts_deployment_author_created_idx", + "columns": [ + { + "expression": "author_login", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_updated_idx": { + "name": "pull_request_facts_deployment_updated_idx", + "columns": [ + { + "expression": "updated_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_facts_repository_id_repositories_id_fk": { + "name": "pull_request_facts_repository_id_repositories_id_fk", + "tableFrom": "pull_request_facts", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pull_request_facts_source_control_provider_check": { + "name": "pull_request_facts_source_control_provider_check", + "value": "\"pull_request_facts\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_sync_states": { + "name": "pull_request_sync_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_incremental_updated_at": { + "name": "last_incremental_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_attempted_sync_at": { + "name": "last_attempted_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_sync_states_repo_unique": { + "name": "pull_request_sync_states_repo_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_deployment_updated_idx": { + "name": "pull_request_sync_states_deployment_updated_idx", + "columns": [ + { + "expression": "last_successful_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_cooldown_idx": { + "name": "pull_request_sync_states_cooldown_idx", + "columns": [ + { + "expression": "cooldown_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_sync_states_repository_id_repositories_id_fk": { + "name": "pull_request_sync_states_repository_id_repositories_id_fk", + "tableFrom": "pull_request_sync_states", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_source_control_provider_idx": { + "name": "repositories_source_control_provider_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_idx": { + "name": "repositories_provider_host_full_name_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_active_installation_idx": { + "name": "repositories_deployment_active_installation_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_github_repo_unique": { + "name": "repositories_deployment_github_repo_unique", + "columns": [ + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_external_repo_unique": { + "name": "repositories_provider_host_external_repo_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_unique": { + "name": "repositories_provider_host_full_name_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_installations_id_fk": { + "name": "repositories_installation_id_github_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_linked_by_user_id_users_id_fk": { + "name": "repositories_linked_by_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["linked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repositories_source_control_provider_check": { + "name": "repositories_source_control_provider_check", + "value": "\"repositories\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + }, + "repositories_github_shape_check": { + "name": "repositories_github_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'github' OR (\"repositories\".\"installation_id\" IS NOT NULL AND \"repositories\".\"github_repo_id\" IS NOT NULL)" + }, + "repositories_gitlab_shape_check": { + "name": "repositories_gitlab_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitlab' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_gitea_shape_check": { + "name": "repositories_gitea_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitea' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_ado_shape_check": { + "name": "repositories_ado_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'ado' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_bitbucket_shape_check": { + "name": "repositories_bitbucket_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'bitbucket' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.sandbox_oidc_targets": { + "name": "sandbox_oidc_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compute_provider": { + "name": "compute_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compute_provider_id": { + "name": "compute_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_file": { + "name": "token_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aws_role_arn": { + "name": "aws_role_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aws_region": { + "name": "aws_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_at": { + "name": "refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_oidc_targets_environment_id_idx": { + "name": "sandbox_oidc_targets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_run_id_idx": { + "name": "sandbox_oidc_targets_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_refresh_at_idx": { + "name": "sandbox_oidc_targets_refresh_at_idx", + "columns": [ + { + "expression": "refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_provider_target_file_unique": { + "name": "sandbox_oidc_targets_provider_target_file_unique", + "columns": [ + { + "expression": "compute_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compute_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_oidc_targets_environment_id_environments_id_fk": { + "name": "sandbox_oidc_targets_environment_id_environments_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_oidc_targets_run_id_task_runs_id_fk": { + "name": "sandbox_oidc_targets_run_id_task_runs_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sandbox_oidc_targets_owner_required": { + "name": "sandbox_oidc_targets_owner_required", + "value": "run_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.setup_qualification_blocks": { + "name": "setup_qualification_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blocked'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_login": { + "name": "github_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_type": { + "name": "github_account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_blocked_at": { + "name": "first_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_blocked_at": { + "name": "last_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_user_id": { + "name": "lifted_by_admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_email": { + "name": "lifted_by_admin_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_qualification_blocks_deployment_user_reason_unique": { + "name": "setup_qualification_blocks_deployment_user_reason_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_deployment_status_idx": { + "name": "setup_qualification_blocks_deployment_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_user_status_idx": { + "name": "setup_qualification_blocks_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setup_qualification_blocks_user_id_users_id_fk": { + "name": "setup_qualification_blocks_user_id_users_id_fk", + "tableFrom": "setup_qualification_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_auth_tokens": { + "name": "slack_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_auth_tokens_expires_at_idx": { + "name": "slack_auth_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_auth_tokens_token_unique": { + "name": "slack_auth_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_conversation_messages": { + "name": "slack_conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_slack_user_id": { + "name": "subject_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_slack_user_id": { + "name": "sender_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_at": { + "name": "message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slack_quick_answer_id": { + "name": "slack_quick_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_conversation_messages_deployment_user_message_at_idx": { + "name": "slack_conversation_messages_deployment_user_message_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_deployment_user_thread_idx": { + "name": "slack_conversation_messages_deployment_user_thread_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_task_id_idx": { + "name": "slack_conversation_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_run_id_idx": { + "name": "slack_conversation_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_team_channel_message_unique": { + "name": "slack_conversation_messages_team_channel_message_unique", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_conversation_messages_subject_user_id_users_id_fk": { + "name": "slack_conversation_messages_subject_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_conversation_messages_sender_user_id_users_id_fk": { + "name": "slack_conversation_messages_sender_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_task_id_tasks_id_fk": { + "name": "slack_conversation_messages_task_id_tasks_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_run_id_task_runs_id_fk": { + "name": "slack_conversation_messages_run_id_task_runs_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_slack_quick_answer_id_slack_quick_answers_id_fk": { + "name": "slack_conversation_messages_slack_quick_answer_id_slack_quick_answers_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "slack_quick_answers", + "columnsFrom": ["slack_quick_answer_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installation_channels": { + "name": "slack_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_installation_id": { + "name": "slack_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installation_channels_installation_id_idx": { + "name": "slack_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "slack_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installation_channels_slack_installation_id_slack_installations_id_fk": { + "name": "slack_installation_channels_slack_installation_id_slack_installations_id_fk", + "tableFrom": "slack_installation_channels", + "tableTo": "slack_installations", + "columnsFrom": ["slack_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installation_channels_unique": { + "name": "slack_installation_channels_unique", + "nullsNotDistinct": false, + "columns": ["slack_installation_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installations": { + "name": "slack_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_domain": { + "name": "team_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_name": { + "name": "enterprise_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_access_token": { + "name": "user_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bot'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count_snapshot": { + "name": "member_count_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_count_snapshot_at": { + "name": "member_count_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installations_bot_user_id_idx": { + "name": "slack_installations_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_installations_active_idx": { + "name": "slack_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installations_installed_by_user_id_users_id_fk": { + "name": "slack_installations_installed_by_user_id_users_id_fk", + "tableFrom": "slack_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installations_team_id_unique": { + "name": "slack_installations_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_quick_answers": { + "name": "slack_quick_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_channel": { + "name": "slack_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_quick_answers_deployment_channel_thread_unique": { + "name": "slack_quick_answers_deployment_channel_thread_unique", + "columns": [ + { + "expression": "slack_channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_quick_answers_deployment_user_idx": { + "name": "slack_quick_answers_deployment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_quick_answers_user_id_users_id_fk": { + "name": "slack_quick_answers_user_id_users_id_fk", + "tableFrom": "slack_quick_answers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_user_mappings": { + "name": "slack_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_user_mappings_user_id_idx": { + "name": "slack_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_user_mappings_user_id_users_id_fk": { + "name": "slack_user_mappings_user_id_users_id_fk", + "tableFrom": "slack_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_user_mappings_unique": { + "name": "slack_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_artifacts": { + "name": "task_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded": { + "name": "uploaded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_artifacts_task_id_idx": { + "name": "task_artifacts_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_run_id_idx": { + "name": "task_artifacts_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_uploaded_idx": { + "name": "task_artifacts_uploaded_idx", + "columns": [ + { + "expression": "uploaded", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_created_at_idx": { + "name": "task_artifacts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_path_idx": { + "name": "task_artifacts_path_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_artifacts_task_id_tasks_id_fk": { + "name": "task_artifacts_task_id_tasks_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_artifacts_run_id_task_runs_id_fk": { + "name": "task_artifacts_run_id_task_runs_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_artifacts_task_id_path_version_unique": { + "name": "task_artifacts_task_id_path_version_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "path", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_inference_usage_events": { + "name": "task_inference_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_tokens": { + "name": "context_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micro_usd": { + "name": "cost_micro_usd", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_completed_at": { + "name": "message_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_inference_usage_events_session_message_unique": { + "name": "task_inference_usage_events_session_message_unique", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_task_id_idx": { + "name": "task_inference_usage_events_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_run_id_idx": { + "name": "task_inference_usage_events_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_created_at_idx": { + "name": "task_inference_usage_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_inference_usage_events_task_id_tasks_id_fk": { + "name": "task_inference_usage_events_task_id_tasks_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_inference_usage_events_run_id_task_runs_id_fk": { + "name": "task_inference_usage_events_run_id_task_runs_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_messages": { + "name": "task_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_messages_task_id_ts_idx": { + "name": "task_messages_task_id_ts_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_run_id_idx": { + "name": "task_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_created_at_idx": { + "name": "task_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_messages_run_id_task_runs_id_fk": { + "name": "task_messages_run_id_task_runs_id_fk", + "tableFrom": "task_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_task_id_tasks_id_fk": { + "name": "task_messages_task_id_tasks_id_fk", + "tableFrom": "task_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_user_id_users_id_fk": { + "name": "task_messages_user_id_users_id_fk", + "tableFrom": "task_messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_messages_task_protocol_ts_event_type_unique": { + "name": "task_messages_task_protocol_ts_event_type_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "protocol", "ts", "event_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pins": { + "name": "task_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pins_deployment_user_task_unique": { + "name": "task_pins_deployment_user_task_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_deployment_user_updated_at_idx": { + "name": "task_pins_deployment_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_task_id_idx": { + "name": "task_pins_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pins_task_id_tasks_id_fk": { + "name": "task_pins_task_id_tasks_id_fk", + "tableFrom": "task_pins", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pins_user_id_users_id_fk": { + "name": "task_pins_user_id_users_id_fk", + "tableFrom": "task_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_platform_issue_reports": { + "name": "task_platform_issue_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_message_id": { + "name": "task_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "slack_posted_at": { + "name": "slack_posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_platform_issue_reports_created_at_idx": { + "name": "task_platform_issue_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_id_created_at_idx": { + "name": "task_platform_issue_reports_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_run_id_created_at_idx": { + "name": "task_platform_issue_reports_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_message_id_unique": { + "name": "task_platform_issue_reports_task_message_id_unique", + "columns": [ + { + "expression": "task_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_platform_issue_reports_task_id_tasks_id_fk": { + "name": "task_platform_issue_reports_task_id_tasks_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_run_id_task_runs_id_fk": { + "name": "task_platform_issue_reports_run_id_task_runs_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_task_message_id_task_messages_id_fk": { + "name": "task_platform_issue_reports_task_message_id_task_messages_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_messages", + "columnsFrom": ["task_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pull_requests": { + "name": "task_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_sha": { + "name": "pr_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_ref": { + "name": "pr_base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_sha": { + "name": "pr_base_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_reaction_id": { + "name": "github_reaction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_check_run_id": { + "name": "github_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_review_comment_id": { + "name": "github_review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pull_requests_task_id_idx": { + "name": "task_pull_requests_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_repository_id_idx": { + "name": "task_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_provider_repository_pr_number_idx": { + "name": "task_pull_requests_provider_repository_pr_number_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pull_requests_task_id_tasks_id_fk": { + "name": "task_pull_requests_task_id_tasks_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pull_requests_repository_id_repositories_id_fk": { + "name": "task_pull_requests_repository_id_repositories_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_pull_requests_task_pr_unique": { + "name": "task_pull_requests_task_pr_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "pr_url"] + } + }, + "policies": {}, + "checkConstraints": { + "task_pull_requests_source_control_provider_check": { + "name": "task_pull_requests_source_control_provider_check", + "value": "\"task_pull_requests\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.task_run_events": { + "name": "task_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_run_events_run_id_created_at_idx": { + "name": "task_run_events_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_task_id_created_at_idx": { + "name": "task_run_events_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_created_at_idx": { + "name": "task_run_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_source_created_at_idx": { + "name": "task_run_events_source_created_at_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_run_events_run_id_task_runs_id_fk": { + "name": "task_run_events_run_id_task_runs_id_fk", + "tableFrom": "task_run_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_run_events_task_id_tasks_id_fk": { + "name": "task_run_events_task_id_tasks_id_fk", + "tableFrom": "task_run_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_runs": { + "name": "task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "task_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fresh'" + }, + "source_run_id": { + "name": "source_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queue_scope": { + "name": "queue_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_phase": { + "name": "task_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_cmd_id": { + "name": "sandbox_cmd_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domain": { + "name": "machine_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domains": { + "name": "machine_domains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "initial_paths": { + "name": "initial_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_port_name": { + "name": "primary_port_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_server_url": { + "name": "sandbox_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_ports": { + "name": "proxy_ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "worker_release_tag": { + "name": "worker_release_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_commit": { + "name": "worker_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_requested_at": { + "name": "snapshot_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_failed_at": { + "name": "snapshot_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "keepalive_ms": { + "name": "keepalive_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sleep_at": { + "name": "sleep_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sleep_requested_at": { + "name": "sleep_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "worker_heartbeat_at": { + "name": "worker_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_value": { + "name": "auth_bypass_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_header_name": { + "name": "auth_bypass_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dequeued_at": { + "name": "dequeued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_started_at": { + "name": "provision_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_ready_at": { + "name": "provision_ready_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "environment_setup_state": { + "name": "environment_setup_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_setup_completed_at": { + "name": "environment_setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "harness_started_at": { + "name": "harness_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "runtime_task_started_at": { + "name": "runtime_task_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_assistant_output_at": { + "name": "first_assistant_output_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_runs_task_id_idx": { + "name": "task_runs_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_queue_scope_idx": { + "name": "task_runs_queue_scope_idx", + "columns": [ + { + "expression": "queue_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_acting_user_id_idx": { + "name": "task_runs_acting_user_id_idx", + "columns": [ + { + "expression": "acting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_snapshot_id_idx": { + "name": "task_runs_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_at_idx": { + "name": "task_runs_sleep_at_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_worker_heartbeat_at_idx": { + "name": "task_runs_worker_heartbeat_at_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_due_idx": { + "name": "task_runs_sleep_check_due_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_idx": { + "name": "task_runs_sleep_check_stale_worker_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"worker_heartbeat_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_idx": { + "name": "task_runs_sleep_check_active_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_snapshot_id_idx": { + "name": "task_runs_source_snapshot_id_idx", + "columns": [ + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_run_id_idx": { + "name": "task_runs_source_run_id_idx", + "columns": [ + { + "expression": "source_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_first_assistant_output_at_idx": { + "name": "task_runs_first_assistant_output_at_idx", + "columns": [ + { + "expression": "first_assistant_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_runs_task_id_tasks_id_fk": { + "name": "task_runs_task_id_tasks_id_fk", + "tableFrom": "task_runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_runs_source_run_id_task_runs_id_fk": { + "name": "task_runs_source_run_id_task_runs_id_fk", + "tableFrom": "task_runs", + "tableTo": "task_runs", + "columnsFrom": ["source_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_runs_acting_user_id_users_id_fk": { + "name": "task_runs_acting_user_id_users_id_fk", + "tableFrom": "task_runs", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "task_runs_kind_check": { + "name": "task_runs_kind_check", + "value": "\"task_runs\".\"kind\" in ('fresh', 'resume')" + }, + "task_runs_harness_check": { + "name": "task_runs_harness_check", + "value": "\"task_runs\".\"harness\" in ('opencode-server')" + } + }, + "isRLSEnabled": false + }, + "public.task_slack_reply_details": { + "name": "task_slack_reply_details", + "schema": "", + "columns": { + "detail_id": { + "name": "detail_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_reply_details_task_id_idx": { + "name": "task_slack_reply_details_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_slack_reply_details_deployment_task_detail_unique": { + "name": "task_slack_reply_details_deployment_task_detail_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_slack_reply_details_task_id_tasks_id_fk": { + "name": "task_slack_reply_details_task_id_tasks_id_fk", + "tableFrom": "task_slack_reply_details", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_start_parallel_counts": { + "name": "task_start_parallel_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parallel_count": { + "name": "parallel_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_window_seconds": { + "name": "activity_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_start_parallel_counts_run_id_unique": { + "name": "task_start_parallel_counts_run_id_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_task_id_started_at_idx": { + "name": "task_start_parallel_counts_task_id_started_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_started_at_idx": { + "name": "task_start_parallel_counts_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_start_parallel_counts_task_id_tasks_id_fk": { + "name": "task_start_parallel_counts_task_id_tasks_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_start_parallel_counts_run_id_task_runs_id_fk": { + "name": "task_start_parallel_counts_run_id_task_runs_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_automation": { + "name": "initiator_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_external_id": { + "name": "actor_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_kind": { + "name": "commit_author_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_user_id": { + "name": "commit_author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_login": { + "name": "commit_author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_external_id": { + "name": "commit_author_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_assignee_login": { + "name": "pr_assignee_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_session_id": { + "name": "linear_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_issue_id": { + "name": "linear_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_provider": { + "name": "model_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_prompt": { + "name": "draft_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_work_kind": { + "name": "requested_work_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "requested_work_kind_source": { + "name": "requested_work_kind_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system_default'" + }, + "requested_work_kind_confidence": { + "name": "requested_work_kind_confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "harness_instructions": { + "name": "harness_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compute_duration_ms": { + "name": "compute_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_initiator_user_id_idx": { + "name": "tasks_initiator_user_id_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_initiator_automation_idx": { + "name": "tasks_initiator_automation_idx", + "columns": [ + { + "expression": "initiator_automation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_workflow_idx": { + "name": "tasks_workflow_idx", + "columns": [ + { + "expression": "workflow", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_visibility_activity_at_idx": { + "name": "tasks_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_harness_session_id_idx": { + "name": "tasks_harness_session_id_idx", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_timestamp_idx": { + "name": "tasks_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_deployment_activity_at_idx": { + "name": "tasks_deployment_activity_at_idx", + "columns": [ + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_initiator_user_id_users_id_fk": { + "name": "tasks_initiator_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["initiator_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_initiator_automation_automations_key_fk": { + "name": "tasks_initiator_automation_automations_key_fk", + "tableFrom": "tasks", + "tableTo": "automations", + "columnsFrom": ["initiator_automation"], + "columnsTo": ["key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_commit_author_user_id_users_id_fk": { + "name": "tasks_commit_author_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["commit_author_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_initiator_shape_check": { + "name": "tasks_initiator_shape_check", + "value": "(\"tasks\".\"initiator_kind\" = 'user' AND \"tasks\".\"initiator_automation\" IS NULL AND (\"tasks\".\"initiator_user_id\" IS NOT NULL OR \"tasks\".\"actor_external_id\" IS NOT NULL)) OR (\"tasks\".\"initiator_kind\" = 'automation' AND \"tasks\".\"initiator_automation\" IS NOT NULL AND \"tasks\".\"initiator_user_id\" IS NULL)" + }, + "tasks_workflow_check": { + "name": "tasks_workflow_check", + "value": "\"tasks\".\"workflow\" in ('standard', 'pr_review', 'pr_conflict_resolve', 'scan', 'mcp_recommendations', 'setup_onboarding', 'env_snapshot', 'eval')" + }, + "tasks_surface_check": { + "name": "tasks_surface_check", + "value": "\"tasks\".\"surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')" + }, + "tasks_trigger_check": { + "name": "tasks_trigger_check", + "value": "\"tasks\".\"trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "tasks_visibility_check": { + "name": "tasks_visibility_check", + "value": "\"tasks\".\"visibility\" in ('visible', 'hidden')" + }, + "tasks_state_check": { + "name": "tasks_state_check", + "value": "\"tasks\".\"state\" in ('active', 'completed', 'failed', 'canceled')" + }, + "tasks_harness_check": { + "name": "tasks_harness_check", + "value": "\"tasks\".\"harness\" in ('opencode-server')" + }, + "tasks_requested_work_kind_check": { + "name": "tasks_requested_work_kind_check", + "value": "\"tasks\".\"requested_work_kind\" in ('question', 'plan', 'implement', 'unknown')" + }, + "tasks_requested_work_kind_source_check": { + "name": "tasks_requested_work_kind_source_check", + "value": "\"tasks\".\"requested_work_kind_source\" in ('explicit_bootstrap', 'task_tool', 'llm_classifier', 'inherited', 'system_default')" + }, + "tasks_commit_author_kind_check": { + "name": "tasks_commit_author_kind_check", + "value": "\"tasks\".\"commit_author_kind\" IS NULL OR \"tasks\".\"commit_author_kind\" in ('roomote', 'user', 'external')" + } + }, + "isRLSEnabled": false + }, + "public.teams_installations": { + "name": "teams_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_key": { + "name": "installation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_type": { + "name": "conversation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_app_id": { + "name": "bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_installations_tenant_id_idx": { + "name": "teams_installations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_team_id_idx": { + "name": "teams_installations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_conversation_id_idx": { + "name": "teams_installations_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_active_idx": { + "name": "teams_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_installations_installation_key_unique": { + "name": "teams_installations_installation_key_unique", + "nullsNotDistinct": false, + "columns": ["installation_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams_user_mappings": { + "name": "teams_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "teams_user_id": { + "name": "teams_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_tenant_id": { + "name": "teams_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_aad_object_id": { + "name": "teams_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_user_mappings_aad_object_idx": { + "name": "teams_user_mappings_aad_object_idx", + "columns": [ + { + "expression": "teams_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "teams_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_user_mappings_user_id_idx": { + "name": "teams_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_user_mappings_user_id_users_id_fk": { + "name": "teams_user_mappings_user_id_users_id_fk", + "tableFrom": "teams_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_user_mappings_unique": { + "name": "teams_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["teams_user_id", "teams_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_user_mappings": { + "name": "telegram_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_user_mappings_user_id_idx": { + "name": "telegram_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_user_mappings_user_id_users_id_fk": { + "name": "telegram_user_mappings_user_id_users_id_fk", + "tableFrom": "telegram_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_user_mappings_unique": { + "name": "telegram_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["telegram_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_messages": { + "name": "tracked_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracked_messages_kind_dedupe_key_unique": { + "name": "tracked_messages_kind_dedupe_key_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_work_item_id_idx": { + "name": "tracked_messages_work_item_id_idx", + "columns": [ + { + "expression": "work_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_channel_message_idx": { + "name": "tracked_messages_channel_message_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_automation_channel_posted_idx": { + "name": "tracked_messages_automation_channel_posted_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "posted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_messages_work_item_id_work_items_id_fk": { + "name": "tracked_messages_work_item_id_work_items_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "work_items", + "columnsFrom": ["work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_automation_key_automations_key_fk": { + "name": "tracked_messages_automation_key_automations_key_fk", + "tableFrom": "tracked_messages", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_created_by_user_id_users_id_fk": { + "name": "tracked_messages_created_by_user_id_users_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_api_keys_user_id_idx": { + "name": "user_api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_api_keys_user_deployment_provider_unique": { + "name": "user_api_keys_user_deployment_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_users_id_fk": { + "name": "user_api_keys_user_id_users_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "analytics_id": { + "name": "analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by_invite_id": { + "name": "invited_by_invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_analytics_id_unique_idx": { + "name": "users_analytics_id_unique_idx", + "columns": [ + { + "expression": "analytics_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "succeeded_at": { + "name": "succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_provider_delivery_id_unique": { + "name": "webhooks_provider_delivery_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_event_idx": { + "name": "webhooks_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_created_at_idx": { + "name": "webhooks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhooks_status_exclusive": { + "name": "webhooks_status_exclusive", + "value": "(\n (succeeded_at IS NOT NULL)::int +\n (failed_at IS NOT NULL)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_by_user_id": { + "name": "selected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_work_item_id": { + "name": "source_work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_prompt": { + "name": "execution_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_context": { + "name": "investigation_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_kind": { + "name": "action_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repository_ids": { + "name": "repository_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "target_repository_full_name": { + "name": "target_repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_environment_id": { + "name": "target_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_readiness": { + "name": "workspace_readiness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readiness_message": { + "name": "readiness_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launched_task_id": { + "name": "launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_error": { + "name": "launch_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_source_task_idx": { + "name": "work_items_source_task_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_kind_status_idx": { + "name": "work_items_kind_status_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_automation_key_fingerprint_idx": { + "name": "work_items_automation_key_fingerprint_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_fingerprint_idx": { + "name": "work_items_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_launched_task_id_idx": { + "name": "work_items_launched_task_id_idx", + "columns": [ + { + "expression": "launched_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_source_task_kind_sort_order_unique": { + "name": "work_items_source_task_kind_sort_order_unique", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_items_automation_key_automations_key_fk": { + "name": "work_items_automation_key_automations_key_fk", + "tableFrom": "work_items", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_task_id_tasks_id_fk": { + "name": "work_items_source_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_selected_by_user_id_users_id_fk": { + "name": "work_items_selected_by_user_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": ["selected_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_work_item_id_work_items_id_fk": { + "name": "work_items_source_work_item_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": ["source_work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_target_environment_id_environments_id_fk": { + "name": "work_items_target_environment_id_environments_id_fk", + "tableFrom": "work_items", + "tableTo": "environments", + "columnsFrom": ["target_environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_launched_task_id_tasks_id_fk": { + "name": "work_items_launched_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d5823dd4..e9203540 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1784053149565, "tag": "0010_complete_scream", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1784118355149, + "tag": "0011_flimsy_sally_floyd", + "breakpoints": true } ] } diff --git a/packages/db/src/lib/__tests__/environment-verification.test.ts b/packages/db/src/lib/__tests__/environment-verification.test.ts new file mode 100644 index 00000000..199f8e83 --- /dev/null +++ b/packages/db/src/lib/__tests__/environment-verification.test.ts @@ -0,0 +1,213 @@ +import { randomUUID } from 'node:crypto'; + +import { environmentConfigSchema } from '@roomote/types'; + +import { + db, + environmentFactory, + environments, + eq, + recordEnvironmentVerification, + beginEnvironmentVerification, + updateEnvironmentDefinition, +} from '../../server'; + +function buildConfig(overrides?: { + name?: string; + description?: string; + install?: string; +}) { + const name = overrides?.name ?? `Verify Test ${randomUUID().slice(0, 8)}`; + return environmentConfigSchema.parse({ + name, + description: overrides?.description, + repositories: [ + { + repository: 'verify-test/example', + commands: overrides?.install + ? [{ name: 'Install', run: overrides.install }] + : undefined, + }, + ], + }); +} + +async function createUnverifiedEnvironment(configOverrides?: { + description?: string; + install?: string; +}) { + const config = buildConfig(configOverrides); + return environmentFactory.create({ + name: config.name, + description: config.description ?? null, + config, + createdByUserId: null, + isVerified: false, + verificationError: null, + }); +} + +describe('environment verification state', () => { + it('records a successful verification when the task id matches', async () => { + const environment = await createUnverifiedEnvironment(); + await beginEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: 'task-1', + }); + + const result = await recordEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: 'task-1', + success: true, + }); + + expect(result.recorded).toBe(true); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.isVerified).toBe(true); + expect(updated?.verifiedAt).toBeInstanceOf(Date); + expect(updated?.verificationError).toBeNull(); + }); + + it('records a failed verification with a sanitized error and keeps the environment usable', async () => { + const environment = await createUnverifiedEnvironment(); + await beginEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: 'task-2', + }); + + const result = await recordEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: 'task-2', + success: false, + error: 'Server failed to start on port 3000', + }); + + expect(result.recorded).toBe(true); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.isVerified).toBe(false); + expect(updated?.verifiedAt).toBeNull(); + expect(updated?.verificationError).toBe( + 'Server failed to start on port 3000', + ); + }); + + it('rejects a stale verification task id so a newer attempt cannot be overwritten', async () => { + const environment = await createUnverifiedEnvironment(); + // A newer attempt is registered. + await beginEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: 'task-new', + }); + + const result = await recordEnvironmentVerification(db, { + environmentId: environment.id, + verificationTaskId: 'task-stale', + success: true, + }); + + expect(result.recorded).toBe(false); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.isVerified).toBe(false); + expect(updated?.verificationTaskId).toBe('task-new'); + }); + + it('clears verification on a runtime-affecting (config) edit', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + config: buildConfig({ install: 'npm install' }), + isVerified: true, + verifiedAt: new Date(), + verificationTaskId: 'task-old', + verificationError: null, + }); + + const nextConfig = buildConfig({ + name: environment.name, + install: 'npm ci', + }); + + const { verificationCleared } = await updateEnvironmentDefinition(db, { + environmentId: environment.id, + fields: { config: nextConfig }, + }); + + expect(verificationCleared).toBe(true); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.isVerified).toBe(false); + expect(updated?.verifiedAt).toBeNull(); + expect(updated?.verificationTaskId).toBeNull(); + }); + + it('preserves verification on a metadata-only (description) edit', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + config: buildConfig({ description: 'before' }), + description: 'before', + isVerified: true, + verifiedAt: new Date(), + verificationTaskId: 'task-keep', + verificationError: null, + }); + + const { verificationCleared } = await updateEnvironmentDefinition(db, { + environmentId: environment.id, + fields: { description: 'after (metadata only)' }, + }); + + expect(verificationCleared).toBe(false); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.isVerified).toBe(true); + expect(updated?.verificationTaskId).toBe('task-keep'); + }); + + it('does not clear verification for declarative edits (preserveVerification)', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + config: buildConfig({ install: 'npm install' }), + isVerified: true, + verifiedAt: new Date(), + verificationTaskId: 'task-declarative', + verificationError: null, + }); + + const nextConfig = buildConfig({ + name: environment.name, + install: 'npm ci', + }); + + const { verificationCleared } = await updateEnvironmentDefinition(db, { + environmentId: environment.id, + fields: { config: nextConfig }, + preserveVerification: true, + }); + + expect(verificationCleared).toBe(false); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.isVerified).toBe(true); + expect(updated?.verificationTaskId).toBe('task-declarative'); + }); +}); diff --git a/packages/db/src/lib/declarative-environments.ts b/packages/db/src/lib/declarative-environments.ts index 2b7e4293..80939be4 100644 --- a/packages/db/src/lib/declarative-environments.ts +++ b/packages/db/src/lib/declarative-environments.ts @@ -441,6 +441,7 @@ async function updateDeclarativeEnvironment( config, }, repositoryIds, + preserveVerification: true, configVersion: { config, name: config.name, diff --git a/packages/db/src/lib/environment-definitions.ts b/packages/db/src/lib/environment-definitions.ts index 61bce227..ea285acb 100644 --- a/packages/db/src/lib/environment-definitions.ts +++ b/packages/db/src/lib/environment-definitions.ts @@ -22,13 +22,42 @@ type UpdateEnvironmentDefinitionInput = { updatedAt?: Date; repositoryIds?: string[]; configVersion?: Omit; + /** + * When true, a runtime-affecting edit does not clear verification. Reserved + * for the declarative (file-managed) path, whose environments are treated as + * verified and never run the onboarding verification task. + */ + preserveVerification?: boolean; }; type UpdateEnvironmentDefinitionResult = { updated: boolean; snapshotsInvalidated: boolean; + /** + * True when the update was a runtime-affecting edit that cleared verification + * (config or repository-mapping change, and `preserveVerification` was not + * set). Callers can use this to re-register a fresh verification attempt. + */ + verificationCleared: boolean; }; +/** + * Fields applied to reset an environment back to the unverified/configured + * state. Used both when a runtime-affecting edit invalidates a prior + * verification and when a fresh verification retry starts. + * + * Clearing `verificationTaskId` invalidates any in-flight verification attempt: + * because `recordEnvironmentVerification` rejects a null/mismatched task id, a + * stale verification task cannot record a result against an edited + * configuration. + */ +const VERIFICATION_RESET_FIELDS = { + isVerified: false, + verifiedAt: null, + verificationError: null, + verificationTaskId: null, +} as const; + function hasOwnKey( value: T, key: K, @@ -96,7 +125,11 @@ async function updateEnvironmentDefinitionLocked( .for('update'); if (!currentEnvironment) { - return { updated: false, snapshotsInvalidated: false }; + return { + updated: false, + snapshotsInvalidated: false, + verificationCleared: false, + }; } const changedFields: EnvironmentDefinitionFields = {}; @@ -146,13 +179,30 @@ async function updateEnvironmentDefinitionLocked( Object.keys(changedFields).length > 0 || repositoryIdsChanged; if (!hasEnvironmentFieldChanges) { - return { updated: false, snapshotsInvalidated: false }; + return { + updated: false, + snapshotsInvalidated: false, + verificationCleared: false, + }; } + // A runtime-affecting edit is any change to the config JSON (repositories, + // install/setup commands, services and start commands, environment + // variables, MCP/runtime configuration, snapshot or compute configuration) + // or to the repository mappings. Metadata-only edits (name/description + // handled through `changedFields` without a config change) must preserve + // verification. Centralizing this here keeps the API, Settings, agent, and + // declarative write paths from drifting. + const isRuntimeAffectingEdit = + changedFields.config !== undefined || repositoryIdsChanged; + const verificationCleared = + isRuntimeAffectingEdit && !input.preserveVerification; + await dbOrTx .update(environments) .set({ ...changedFields, + ...(verificationCleared ? VERIFICATION_RESET_FIELDS : {}), updatedAt: now, }) .where(eq(environments.id, input.environmentId)); @@ -186,5 +236,89 @@ async function updateEnvironmentDefinitionLocked( } } - return { updated: true, snapshotsInvalidated: true }; + return { updated: true, snapshotsInvalidated: true, verificationCleared }; +} + +type RecordEnvironmentVerificationInput = { + environmentId: string; + verificationTaskId: string; + success: boolean; + /** Pre-sanitized, user-safe failure message. Only used when success=false. */ + error?: string | null; + verifiedAt?: Date; +}; + +/** + * Record the terminal result of an environment verification task. + * + * Only applies when `verificationTaskId` still matches the task currently + * stored on the environment, so a stale or superseded verification attempt + * cannot overwrite a newer one. Returns whether the row was updated. + * + * - success=true: marks the environment verified, sets `verifiedAt`, clears + * `verificationError`. + * - success=false: leaves the environment usable but unverified and stores the + * sanitized failure message. + */ +export async function recordEnvironmentVerification( + dbOrTx: DatabaseOrTransaction, + input: RecordEnvironmentVerificationInput, +): Promise<{ recorded: boolean }> { + return runInTransactionIfAvailable(dbOrTx, async (tx) => { + const [current] = await tx + .select({ verificationTaskId: environments.verificationTaskId }) + .from(environments) + .where(eq(environments.id, input.environmentId)) + .for('update'); + + if (!current) { + return { recorded: false }; + } + + // Reject a mismatched verification task so a stale attempt cannot clobber + // a newer one. The environment must currently point at this task. + if (current.verificationTaskId !== input.verificationTaskId) { + return { recorded: false }; + } + + if (input.success) { + await tx + .update(environments) + .set({ + isVerified: true, + verifiedAt: input.verifiedAt ?? new Date(), + verificationError: null, + }) + .where(eq(environments.id, input.environmentId)); + } else { + await tx + .update(environments) + .set({ + isVerified: false, + verifiedAt: null, + verificationError: input.error ?? null, + }) + .where(eq(environments.id, input.environmentId)); + } + + return { recorded: true }; + }); +} + +/** + * Attach a (re)started verification task to an environment and reset it to the + * unverified/configured state. A new retry replaces any prior + * `verificationTaskId` and clears the previous error and verified timestamp. + */ +export async function beginEnvironmentVerification( + dbOrTx: DatabaseOrTransaction, + input: { environmentId: string; verificationTaskId: string }, +): Promise { + await dbOrTx + .update(environments) + .set({ + ...VERIFICATION_RESET_FIELDS, + verificationTaskId: input.verificationTaskId, + }) + .where(eq(environments.id, input.environmentId)); } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 84fe2538..5d01cb10 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2646,6 +2646,23 @@ export const environments = pgTable( // declarative set. declarativeSource: text('declarative_source'), + // Verification state. "Configured" means the environment definition exists + // but its current runtime configuration has not been confirmed to work by a + // follow-up verification task. "Verified" means a verification task + // explicitly reported success. The migration default keeps every existing + // environment verified; new environments start unverified. Any + // runtime-affecting edit clears verification through + // updateEnvironmentDefinition, so these fields cannot drift across the API, + // Settings, agent, and declarative write paths. + isVerified: boolean('is_verified').notNull().default(true), + // Roomote task id of the verification task that most recently ran (or is + // running) for the current configuration. Not a task-run id. + verificationTaskId: text('verification_task_id'), + verifiedAt: timestamp('verified_at'), + // Latest user-safe verification failure message. Never stores secrets or + // full environment YAML. + verificationError: text('verification_error'), + snapshotId: text('snapshot_id'), snapshotCreatedAt: timestamp('snapshot_created_at'), snapshotExpiresAt: timestamp('snapshot_expires_at'), diff --git a/packages/types/src/environment-definition-tasks.ts b/packages/types/src/environment-definition-tasks.ts index 299f8af4..4813d19d 100644 --- a/packages/types/src/environment-definition-tasks.ts +++ b/packages/types/src/environment-definition-tasks.ts @@ -72,6 +72,22 @@ Do not treat clearly pre-existing repository test failures as an automatic block Create the environment when validation is sufficient.`; } +/** + * Prompt for a standalone environment verification task launched by the + * verification-retry command. The task runs inside the target environment and + * must record its result through the `record_verification` MCP action. + */ +export function buildEnvironmentVerificationPrompt(input: { + environmentId: string; + environmentName: string; +}): string { + return `Verify that the ${PRODUCT_NAME} environment "${input.environmentName}" (id ${input.environmentId}) is running correctly. + +Use localhost or the environment's initial URL to confirm the expected service responds successfully, and confirm there are no obvious startup failures blocking basic use. Preparing the environment can take 5 minutes or more, so be patient before deciding startup is stuck. + +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.`; +} + export function appendEnvironmentDefinitionGuidance( prompt: string, guidance: string | null | undefined, diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index 078ec52c..3ff645b5 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -925,6 +925,15 @@ const sharedTaskPayloadSchema = z.object({ */ environmentDefinitionId: z.string().uuid().optional(), + /** + * Marks this task as an environment verification flow for the given + * environment id. Set by the verification-retry command and by the + * environment-setup skill's follow-up verification task. Only tasks carrying + * this marker may record a verification result for that environment through + * the `record_verification` MCP action. + */ + verifiesEnvironmentId: z.string().uuid().optional(), + /** * Optional validated repository subset to prepare when `repo` is the * ALL_REPOSITORIES sentinel. This allows multi-repo tasks to start a shared From 2437b64caba7be5b9bb85ffa63e39704b18e8bb7 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 13:34:41 +0000 Subject: [PATCH 02/21] [Fix] Prevent concurrency races in environment verification registration and retry --- .../environments/createEnvironment.ts | 38 +++---- .../environments/updateEnvironment.ts | 20 ++-- .../setup/StepInvoke.client.test.tsx | 2 +- .../trpc/commands/environments/index.test.ts | 102 ++++++++++++------ .../src/trpc/commands/environments/index.ts | 77 +++++++------ .../environment-verification.test.ts | 35 ++++++ .../db/src/lib/environment-definitions.ts | 46 +++++++- 7 files changed, 220 insertions(+), 100 deletions(-) diff --git a/apps/api/src/handlers/environments/createEnvironment.ts b/apps/api/src/handlers/environments/createEnvironment.ts index 60810b2d..7d2c33cd 100644 --- a/apps/api/src/handlers/environments/createEnvironment.ts +++ b/apps/api/src/handlers/environments/createEnvironment.ts @@ -2,7 +2,6 @@ import type { Context } from 'hono'; import { and, - beginEnvironmentVerification, createEnvironmentConfigVersionSnapshot, db, environmentRepositoryMappings, @@ -174,21 +173,21 @@ export async function attachEnvironmentIdToTaskRun( } /** - * Register the calling task run as the current verification attempt for an - * 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`; storing the caller's - * taskId here means that recording matches server-side without the agent - * having to hand-pass any task id. Only run-token callers register an attempt. + * 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 registerEnvironmentVerificationForTaskRun( +export async function resolveCallingVerificationTaskId( auth: McpAuth, - environmentId: string, -): Promise { +): Promise { const runId = extractRunId(auth); if (!runId) { - return; + return undefined; } const taskRun = await db.query.taskRuns.findFirst({ @@ -196,14 +195,7 @@ export async function registerEnvironmentVerificationForTaskRun( columns: { taskId: true }, }); - if (!taskRun?.taskId) { - return; - } - - await beginEnvironmentVerification(db, { - environmentId, - verificationTaskId: taskRun.taskId, - }); + return taskRun?.taskId ?? undefined; } /** @@ -300,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) @@ -310,8 +304,11 @@ export async function createEnvironment( description: config.description, config, // New environments start configured but not yet verified. A - // follow-up verification task confirms the runtime works. + // 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 }); @@ -346,7 +343,6 @@ export async function createEnvironment( }); await attachEnvironmentIdToTaskRun(auth, created.id); - await registerEnvironmentVerificationForTaskRun(auth, created.id); return c.json({ success: true, diff --git a/apps/api/src/handlers/environments/updateEnvironment.ts b/apps/api/src/handlers/environments/updateEnvironment.ts index 42630954..1b6e5a4e 100644 --- a/apps/api/src/handlers/environments/updateEnvironment.ts +++ b/apps/api/src/handlers/environments/updateEnvironment.ts @@ -23,7 +23,7 @@ import { attachEnvironmentIdToTaskRun, getEnvironmentRepositoryConfigError, isEnvironmentNameUniqueViolation, - registerEnvironmentVerificationForTaskRun, + resolveCallingVerificationTaskId, resolveEnvironmentWriteUserId, } from './createEnvironment'; @@ -156,10 +156,12 @@ export async function updateEnvironment( .map((name) => repoMap.get(name)) .filter((repositoryId): repositoryId is string => Boolean(repositoryId)); - const { verificationCleared } = await db.transaction(async (tx) => { + const verificationTaskId = await resolveCallingVerificationTaskId(auth); + + await db.transaction(async (tx) => { const now = new Date(); - return updateEnvironmentDefinition(tx, { + await updateEnvironmentDefinition(tx, { environmentId: id, fields: { name: config.name, @@ -168,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, @@ -180,13 +187,6 @@ export async function updateEnvironment( await attachEnvironmentIdToTaskRun(auth, id); - // A runtime-affecting update resets verification; register the calling - // setup task as the current verification attempt so its follow-up - // record_verification matches server-side. - if (verificationCleared) { - await registerEnvironmentVerificationForTaskRun(auth, id); - } - return c.json({ success: true, environmentId: id, 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/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index ae5f0f5c..2e25ad22 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -5,6 +5,7 @@ const { mockGetBranches, mockGetRepositories, mockBeginEnvironmentVerification, + mockActiveVerificationRuns, } = vi.hoisted(() => ({ mockCheckRepoAccess: vi.fn(), mockDbSelect: vi.fn(), @@ -26,6 +27,9 @@ const { }, ]), mockBeginEnvironmentVerification: vi.fn(), + // Active verification run seen inside the retry critical section. Each entry + // is returned by the locked transaction's active-run lookup. + mockActiveVerificationRuns: [] as Array<{ taskId: string }>, })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -60,6 +64,24 @@ vi.mock('@roomote/db/server', () => ({ tasks: {}, updateEnvironmentDefinition: vi.fn(), users: {}, + withEnvironmentVerificationRetryLock: async ( + _environmentId: string, + mutation: (tx: unknown) => Promise, + ) => { + // Provide a transaction whose active-run lookup returns the seeded rows. + const tx = { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => mockActiveVerificationRuns, + }), + }), + }), + }), + }; + return mutation(tx); + }, })); vi.mock('@/lib/server', () => ({ @@ -143,30 +165,25 @@ describe('startEnvironmentDefinitionTaskCommand', () => { describe('retryEnvironmentVerificationCommand', () => { beforeEach(() => { vi.clearAllMocks(); + mockActiveVerificationRuns.length = 0; mockEnqueueTask.mockResolvedValue({ taskId: 'task-verify-1', id: 'run-verify-1', }); }); - it('enqueues a verification task and registers the new attempt', async () => { - mockDbSelect - // Environment lookup. - .mockReturnValueOnce({ - from: () => ({ - where: () => ({ - limit: async () => [{ id: 'env-1', name: 'My Env' }], - }), - }), - }) - // Active verification task lookup (none active). - .mockReturnValueOnce({ - from: () => ({ - where: () => ({ - orderBy: () => ({ limit: async () => [] }), - }), + function mockEnvironmentLookup() { + mockDbSelect.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [{ id: 'env-1', name: 'My Env' }], }), - }); + }), + }); + } + + it('enqueues a verification task and registers the new attempt', async () => { + mockEnvironmentLookup(); const result = await retryEnvironmentVerificationCommand(buildMockAuth(), { environmentId: 'env-1', @@ -192,23 +209,9 @@ describe('retryEnvironmentVerificationCommand', () => { }); it('rejects when a verification task is already active', async () => { - mockDbSelect - .mockReturnValueOnce({ - from: () => ({ - where: () => ({ - limit: async () => [{ id: 'env-1', name: 'My Env' }], - }), - }), - }) - .mockReturnValueOnce({ - from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: async () => [{ taskId: 'task-active' }], - }), - }), - }), - }); + mockEnvironmentLookup(); + // A verification run is already active inside the locked critical section. + mockActiveVerificationRuns.push({ taskId: 'task-active' }); await expect( retryEnvironmentVerificationCommand(buildMockAuth(), { @@ -220,6 +223,37 @@ describe('retryEnvironmentVerificationCommand', () => { expect(mockBeginEnvironmentVerification).not.toHaveBeenCalled(); }); + it('serializes concurrent retries so only one verification task is enqueued', async () => { + mockEnvironmentLookup(); + mockEnvironmentLookup(); + + // Model the advisory lock's serialization: the first retry through the + // critical section sees no active run and enqueues, and its registration + // makes the attempt active for the second retry. + let attempt = 0; + mockEnqueueTask.mockImplementation(async () => { + attempt += 1; + // After the first enqueue+register, a subsequent critical section sees + // the active run and must reject before enqueueing again. + mockActiveVerificationRuns.push({ taskId: `task-verify-${attempt}` }); + return { taskId: `task-verify-${attempt}`, id: `run-verify-${attempt}` }; + }); + + const first = await retryEnvironmentVerificationCommand(buildMockAuth(), { + environmentId: 'env-1', + }); + expect(first).toEqual({ taskId: 'task-verify-1' }); + + await expect( + retryEnvironmentVerificationCommand(buildMockAuth(), { + environmentId: 'env-1', + }), + ).rejects.toThrow('already being verified'); + + expect(mockEnqueueTask).toHaveBeenCalledTimes(1); + expect(mockBeginEnvironmentVerification).toHaveBeenCalledTimes(1); + }); + it('rejects when the environment does not exist', async () => { mockDbSelect.mockReturnValueOnce({ from: () => ({ diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index ecf37955..7bc4c6f9 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -19,6 +19,8 @@ import { sql, updateEnvironmentDefinition, users, + withEnvironmentVerificationRetryLock, + type DatabaseOrTransaction, type SQL, type EnvironmentConfigVersionSource, } from '@roomote/db/server'; @@ -844,11 +846,13 @@ export async function startEnvironmentDefinitionTaskCommand( /** * Find an active verification task for the given environment, if any. A * verification task carries the `verifiesEnvironmentId` marker in its payload. + * Accepts a transaction so the check can run inside the retry critical section. */ async function getActiveVerificationTaskId( + dbOrTx: DatabaseOrTransaction, environmentId: string, ): Promise { - const [job] = await db + const [job] = await dbOrTx .select({ taskId: taskRuns.taskId }) .from(taskRuns) .where( @@ -868,6 +872,12 @@ async function getActiveVerificationTaskId( * target environment, verifies it, and records the result through the * `record_verification` MCP action. Resets the environment to the unverified * state and stores the new verification task id. + * + * The active-run check, task enqueue, and attempt registration are serialized + * per environment by a `withEnvironmentVerificationRetryLock` advisory lock, so + * two concurrent retries cannot both pass the check and enqueue duplicate + * verification runs; the second waits for the first to commit and then sees the + * active attempt and is rejected. */ export async function retryEnvironmentVerificationCommand( auth: UserAuthSuccess, @@ -892,44 +902,49 @@ export async function retryEnvironmentVerificationCommand( throw new Error('Environment not found'); } - const activeVerificationTaskId = await getActiveVerificationTaskId( - environment.id, - ); - - if (activeVerificationTaskId) { - throw new Error('This environment is already being verified.'); - } - const prompt = buildEnvironmentVerificationPrompt({ environmentId: environment.id, environmentName: environment.name, }); - const launchResult = await enqueueTask({ - title: `Verify environment: ${environment.name}`, - task: { - type: TaskPayloadKind.StandardTask, - payload: { - repo: ALL_REPOSITORIES, - environmentId: environment.id, - verifiesEnvironmentId: environment.id, - description: prompt, + return withEnvironmentVerificationRetryLock(environment.id, async (tx) => { + const activeVerificationTaskId = await getActiveVerificationTaskId( + tx, + environment.id, + ); + + if (activeVerificationTaskId) { + throw new Error('This environment is already being verified.'); + } + + const launchResult = await enqueueTask({ + title: `Verify environment: ${environment.name}`, + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: ALL_REPOSITORIES, + environmentId: environment.id, + verifiesEnvironmentId: environment.id, + description: prompt, + }, }, - }, - initiator: { kind: 'user', userId }, - workflow: 'standard', - surface: 'web', - trigger: 'manual', - }); + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); - // Reset verification state and point the environment at the new task so - // record_verification can only be applied by this attempt. - await beginEnvironmentVerification(db, { - environmentId: environment.id, - verificationTaskId: launchResult.taskId, - }); + // Reset verification state and point the environment at the new task so + // record_verification can only be applied by this attempt. Done inside the + // advisory-locked transaction so a concurrent retry blocks until this + // attempt is registered and then observes it as active. + await beginEnvironmentVerification(tx, { + environmentId: environment.id, + verificationTaskId: launchResult.taskId, + }); - return { taskId: launchResult.taskId }; + return { taskId: launchResult.taskId }; + }); } export async function cancelEnvironmentDefinitionTaskCommand( diff --git a/packages/db/src/lib/__tests__/environment-verification.test.ts b/packages/db/src/lib/__tests__/environment-verification.test.ts index 199f8e83..011f6db8 100644 --- a/packages/db/src/lib/__tests__/environment-verification.test.ts +++ b/packages/db/src/lib/__tests__/environment-verification.test.ts @@ -180,6 +180,41 @@ describe('environment verification state', () => { expect(updated?.verificationTaskId).toBe('task-keep'); }); + it('atomically registers a new verification task when a runtime-affecting edit clears verification', async () => { + const environment = await environmentFactory.create({ + createdByUserId: null, + config: buildConfig({ install: 'npm install' }), + isVerified: true, + verifiedAt: new Date(), + verificationTaskId: 'task-old', + verificationError: 'stale error', + }); + + const nextConfig = buildConfig({ + name: environment.name, + install: 'npm ci', + }); + + const { verificationCleared } = await updateEnvironmentDefinition(db, { + environmentId: environment.id, + fields: { config: nextConfig }, + registerVerificationTaskId: 'task-new-setup', + }); + + expect(verificationCleared).toBe(true); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + // Verification is reset, but the calling task is registered atomically + // rather than left null. + expect(updated?.isVerified).toBe(false); + expect(updated?.verifiedAt).toBeNull(); + expect(updated?.verificationError).toBeNull(); + expect(updated?.verificationTaskId).toBe('task-new-setup'); + }); + it('does not clear verification for declarative edits (preserveVerification)', async () => { const environment = await environmentFactory.create({ createdByUserId: null, diff --git a/packages/db/src/lib/environment-definitions.ts b/packages/db/src/lib/environment-definitions.ts index ea285acb..90190120 100644 --- a/packages/db/src/lib/environment-definitions.ts +++ b/packages/db/src/lib/environment-definitions.ts @@ -1,7 +1,7 @@ import type { EnvironmentConfig } from '@roomote/types'; -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; -import type { DatabaseOrTransaction } from '../db'; +import { db, type DatabaseOrTransaction } from '../db'; import { environmentRepositoryMappings, environments } from '../schema'; import { createEnvironmentConfigVersionSnapshot, @@ -28,6 +28,14 @@ type UpdateEnvironmentDefinitionInput = { * verified and never run the onboarding verification task. */ preserveVerification?: boolean; + /** + * When a runtime-affecting edit clears verification, atomically set the + * environment's `verificationTaskId` to this value (the calling verification + * task) instead of leaving it null. Applied under the same row lock as the + * reset, so a concurrent update cannot register an older task against a newer + * configuration. + */ + registerVerificationTaskId?: string; }; type UpdateEnvironmentDefinitionResult = { @@ -202,7 +210,17 @@ async function updateEnvironmentDefinitionLocked( .update(environments) .set({ ...changedFields, - ...(verificationCleared ? VERIFICATION_RESET_FIELDS : {}), + ...(verificationCleared + ? { + ...VERIFICATION_RESET_FIELDS, + // Atomically claim the current verification attempt for the calling + // task under the same row lock as the reset, so a concurrent edit + // cannot register an older task against this newer configuration. + ...(input.registerVerificationTaskId + ? { verificationTaskId: input.registerVerificationTaskId } + : {}), + } + : {}), updatedAt: now, }) .where(eq(environments.id, input.environmentId)); @@ -322,3 +340,25 @@ export async function beginEnvironmentVerification( }) .where(eq(environments.id, input.environmentId)); } + +/** + * Serialize verification-retry attempts for a single environment. + * + * Runs `mutation` inside a transaction that holds a per-environment advisory + * lock (`pg_advisory_xact_lock`), so two concurrent retries cannot both pass an + * "already being verified" check and enqueue duplicate verification runs. The + * lock is released when the transaction commits or rolls back. The callback + * receives the locked transaction so the active-run check, task enqueue, and + * attempt registration all happen inside the same critical section. + */ +export async function withEnvironmentVerificationRetryLock( + environmentId: string, + mutation: (tx: DatabaseOrTransaction) => Promise, +): Promise { + return runInTransactionIfAvailable(db, async (tx) => { + await tx.execute( + sql`SELECT n_xact_lock(hashtext(${`environment-verification-retry:${environmentId}`}))`, + ); + return mutation(tx); + }); +} From 1d074af82b960211260c353efbdf19409a6c7e9a Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 13:47:03 +0000 Subject: [PATCH 03/21] [Fix] Use pg_advisory_xact_lock for verification retry lock --- .../__tests__/environment-snapshots.test.ts | 1 + .../environment-verification.test.ts | 27 +++++++++++++++++++ .../db/src/lib/environment-definitions.ts | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/db/src/lib/__tests__/environment-snapshots.test.ts b/packages/db/src/lib/__tests__/environment-snapshots.test.ts index d37f9512..0bb4d992 100644 --- a/packages/db/src/lib/__tests__/environment-snapshots.test.ts +++ b/packages/db/src/lib/__tests__/environment-snapshots.test.ts @@ -478,6 +478,7 @@ describe('environment snapshot helpers', () => { expect(result).toEqual({ updated: false, snapshotsInvalidated: false, + verificationCleared: false, }); expect(snapshotRow).toEqual( expect.objectContaining({ diff --git a/packages/db/src/lib/__tests__/environment-verification.test.ts b/packages/db/src/lib/__tests__/environment-verification.test.ts index 011f6db8..89e45c28 100644 --- a/packages/db/src/lib/__tests__/environment-verification.test.ts +++ b/packages/db/src/lib/__tests__/environment-verification.test.ts @@ -10,6 +10,7 @@ import { recordEnvironmentVerification, beginEnvironmentVerification, updateEnvironmentDefinition, + withEnvironmentVerificationRetryLock, } from '../../server'; function buildConfig(overrides?: { @@ -245,4 +246,30 @@ describe('environment verification state', () => { expect(updated?.isVerified).toBe(true); expect(updated?.verificationTaskId).toBe('task-declarative'); }); + + it('acquires the retry advisory lock and runs the mutation against the real database', async () => { + const environment = await createUnverifiedEnvironment(); + + // Exercises the real pg_advisory_xact_lock SQL so a wrong function name + // (e.g. n_xact_lock) would fail here instead of only in production. + const result = await withEnvironmentVerificationRetryLock( + environment.id, + async (tx) => { + await beginEnvironmentVerification(tx, { + environmentId: environment.id, + verificationTaskId: 'task-locked', + }); + return 'ok'; + }, + ); + + expect(result).toBe('ok'); + + const updated = await db.query.environments.findFirst({ + where: eq(environments.id, environment.id), + }); + + expect(updated?.verificationTaskId).toBe('task-locked'); + expect(updated?.isVerified).toBe(false); + }); }); diff --git a/packages/db/src/lib/environment-definitions.ts b/packages/db/src/lib/environment-definitions.ts index 90190120..d646a4d1 100644 --- a/packages/db/src/lib/environment-definitions.ts +++ b/packages/db/src/lib/environment-definitions.ts @@ -357,7 +357,7 @@ export async function withEnvironmentVerificationRetryLock( ): Promise { return runInTransactionIfAvailable(db, async (tx) => { await tx.execute( - sql`SELECT n_xact_lock(hashtext(${`environment-verification-retry:${environmentId}`}))`, + sql`SELECT pg_advisory_xact_lock(hashtext(${`environment-verification-retry:${environmentId}`}))`, ); return mutation(tx); }); From 7f624522324dc2d1fc86fa8ad7122b795354ac10 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 16:20:04 +0100 Subject: [PATCH 04/21] Fix onboarding completion before setup finishes --- .../commands/sandbox-session/index.test.ts | 43 +++++++++++++++++++ .../trpc/commands/sandbox-session/index.ts | 16 +++---- .../commands/sandbox-session/session-state.ts | 17 ++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/apps/web/src/trpc/commands/sandbox-session/index.test.ts b/apps/web/src/trpc/commands/sandbox-session/index.test.ts index a06a3c11..f32f63d2 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.test.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.test.ts @@ -8,6 +8,7 @@ import { restoreSnapshotResumeVisiblePromptFields } from '../snapshot-visible-pr import { getSessionState, isWaitingForFirstHarnessMessage, + shouldExposeOnboardingEnvironment, shouldPollForFirstHarnessMessage, } from './session-state'; @@ -292,6 +293,48 @@ describe('shouldPollForFirstHarnessMessage', () => { }); }); +describe('shouldExposeOnboardingEnvironment', () => { + it('does not expose the onboarding environment while setup is still running', () => { + expect( + shouldExposeOnboardingEnvironment({ + taskWorkflow: 'setup_onboarding', + taskRunStatus: RunStatus.Running, + taskRunPhase: 'thinking', + }), + ).toBe(false); + }); + + it('exposes the onboarding environment after setup completes', () => { + expect( + shouldExposeOnboardingEnvironment({ + taskWorkflow: 'setup_onboarding', + taskRunStatus: RunStatus.Completed, + taskRunPhase: null, + }), + ).toBe(true); + }); + + it('exposes the onboarding environment when setup is waiting for the final prompt', () => { + expect( + shouldExposeOnboardingEnvironment({ + taskWorkflow: 'setup_onboarding', + taskRunStatus: RunStatus.Idle, + taskRunPhase: 'waiting_for_prompt', + }), + ).toBe(true); + }); + + it('ignores non-onboarding tasks', () => { + expect( + shouldExposeOnboardingEnvironment({ + taskWorkflow: 'standard', + taskRunStatus: RunStatus.Completed, + taskRunPhase: null, + }), + ).toBe(false); + }); +}); + describe('restoreSnapshotResumeVisiblePromptFields', () => { it('restores commentBody for GitHub follow-up resumes', () => { const payload: Record = { diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index 6d6e227a..52bbb1df 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -47,6 +47,7 @@ import { import { getSessionState, + shouldExposeOnboardingEnvironment, shouldPollForFirstHarnessMessage, } from './session-state'; @@ -586,14 +587,13 @@ export async function getSandboxSessionByTaskIdCommand( const task = taskById; - const onboardingEnvironmentId = - task.workflow === 'setup_onboarding' && - (taskRun.status === RunStatus.Running || - taskRun.status === RunStatus.Completed || - (taskRun.status === RunStatus.Idle && - taskRun.taskPhase === 'waiting_for_prompt')) - ? getEnvironmentDefinitionIdFromPayload(taskRun.payload) - : null; + const onboardingEnvironmentId = shouldExposeOnboardingEnvironment({ + taskWorkflow: task.workflow, + taskRunStatus: taskRun.status, + taskRunPhase: taskRun.taskPhase, + }) + ? getEnvironmentDefinitionIdFromPayload(taskRun.payload) + : null; const [artifacts, onboardingEnvironment] = await Promise.all([ getArtifactsForTaskCommand(auth, { diff --git a/apps/web/src/trpc/commands/sandbox-session/session-state.ts b/apps/web/src/trpc/commands/sandbox-session/session-state.ts index dfb0f29f..a76c201f 100644 --- a/apps/web/src/trpc/commands/sandbox-session/session-state.ts +++ b/apps/web/src/trpc/commands/sandbox-session/session-state.ts @@ -117,3 +117,20 @@ export function shouldPollForFirstHarnessMessage({ return hasInitialPrompt && sessionState === 'interactive'; } + +export function shouldExposeOnboardingEnvironment({ + taskWorkflow, + taskRunStatus, + taskRunPhase, +}: { + taskWorkflow: string | null | undefined; + taskRunStatus: RunStatus; + taskRunPhase: string | null; +}): boolean { + return ( + taskWorkflow === 'setup_onboarding' && + (taskRunStatus === RunStatus.Completed || + (taskRunStatus === RunStatus.Idle && + taskRunPhase === 'waiting_for_prompt')) + ); +} From e5126bd92f4575bfc11822e2de7ad0e7853aa7e8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 15:20:31 +0000 Subject: [PATCH 05/21] [Fix] Block retry verification while initial onboarding verification is active --- .../active-verification-task.test.ts | 111 ++++++++++++++++++ .../trpc/commands/environments/index.test.ts | 18 +-- .../src/trpc/commands/environments/index.ts | 34 +++++- 3 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/trpc/commands/environments/active-verification-task.test.ts diff --git a/apps/web/src/trpc/commands/environments/active-verification-task.test.ts b/apps/web/src/trpc/commands/environments/active-verification-task.test.ts new file mode 100644 index 00000000..9ae23d33 --- /dev/null +++ b/apps/web/src/trpc/commands/environments/active-verification-task.test.ts @@ -0,0 +1,111 @@ +import { + db, + environmentFactory, + runFactory, + taskFactory, + taskRuns, + eq, +} from '@roomote/db/server'; +import { RunStatus } from '@roomote/types'; + +import { getActiveVerificationTaskIdForTest } from './index'; + +describe('getActiveVerificationTaskId', () => { + async function createEnvironment() { + return environmentFactory.create({ createdByUserId: null }); + } + + async function createActiveRun(params: { + taskId: string; + payload: Record; + }) { + const run = await runFactory.create({ + taskId: params.taskId, + payload: params.payload as never, + }); + await db + .update(taskRuns) + .set({ status: RunStatus.Running }) + .where(eq(taskRuns.id, run.id)); + return run; + } + + it('detects an active retry verification task (verifiesEnvironmentId marker)', async () => { + const environment = await createEnvironment(); + const task = await taskFactory.create({ workflow: 'standard' }); + await createActiveRun({ + taskId: task.id, + payload: { + repo: 'test/repo', + verifiesEnvironmentId: environment.id, + description: 'verify', + }, + }); + + await expect( + getActiveVerificationTaskIdForTest(db, environment.id), + ).resolves.toBe(task.id); + }); + + it('detects an active initial onboarding verification (setup_onboarding + environmentDefinitionId)', async () => { + const environment = await createEnvironment(); + const task = await taskFactory.create({ workflow: 'setup_onboarding' }); + await createActiveRun({ + taskId: task.id, + payload: { + repo: 'test/repo', + environmentDefinitionId: environment.id, + description: 'setup + verify', + }, + }); + + // A stale retry must not be able to supersede an in-flight onboarding + // verification. + await expect( + getActiveVerificationTaskIdForTest(db, environment.id), + ).resolves.toBe(task.id); + }); + + it('ignores a setup task for a different environment', async () => { + const environment = await createEnvironment(); + const otherEnvironment = await createEnvironment(); + const task = await taskFactory.create({ workflow: 'setup_onboarding' }); + await createActiveRun({ + taskId: task.id, + payload: { + repo: 'test/repo', + environmentDefinitionId: otherEnvironment.id, + description: 'setup + verify', + }, + }); + + await expect( + getActiveVerificationTaskIdForTest(db, environment.id), + ).resolves.toBeNull(); + }); + + it('does not treat an environmentDefinitionId marker on a non-setup workflow as an active verification', async () => { + const environment = await createEnvironment(); + const task = await taskFactory.create({ workflow: 'standard' }); + await createActiveRun({ + taskId: task.id, + payload: { + repo: 'test/repo', + environmentDefinitionId: environment.id, + description: 'unrelated standard task', + }, + }); + + await expect( + getActiveVerificationTaskIdForTest(db, environment.id), + ).resolves.toBeNull(); + }); + + it('returns null when no active verification task exists', async () => { + const environment = await createEnvironment(); + + await expect( + getActiveVerificationTaskIdForTest(db, environment.id), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index 2e25ad22..13733774 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -58,6 +58,7 @@ vi.mock('@roomote/db/server', () => ({ isNull: vi.fn(), loadEnvironmentSnapshots: vi.fn(), markTaskStartParallelCountEndedAt: vi.fn(), + or: vi.fn(), repositories: {}, sql: vi.fn(), taskRuns: {}, @@ -69,17 +70,20 @@ vi.mock('@roomote/db/server', () => ({ mutation: (tx: unknown) => Promise, ) => { // Provide a transaction whose active-run lookup returns the seeded rows. - const tx = { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: async () => mockActiveVerificationRuns, - }), + const activeRunQuery = { + innerJoin: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => mockActiveVerificationRuns, }), }), }), }; + const tx = { + select: () => ({ + from: () => activeRunQuery, + }), + }; return mutation(tx); }, })); diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 7bc4c6f9..449b70ed 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -15,6 +15,7 @@ import { isNull, loadEnvironmentSnapshots, markTaskStartParallelCountEndedAt, + or, repositories, sql, updateEnvironmentDefinition, @@ -844,9 +845,14 @@ export async function startEnvironmentDefinitionTaskCommand( } /** - * Find an active verification task for the given environment, if any. A - * verification task carries the `verifiesEnvironmentId` marker in its payload. - * Accepts a transaction so the check can run inside the retry critical section. + * Find an active verification task for the given environment, if any. This + * covers both retry-launched verification tasks (marked with + * `verifiesEnvironmentId`) and the initial onboarding setup task (workflow + * `setup_onboarding`, marked with `environmentDefinitionId`) that performs and + * records the first verification. Both are explicitly authorized to record a + * result for the environment, so both must block a concurrent retry from + * superseding an in-flight attempt. Accepts a transaction so the check can run + * inside the retry critical section. */ async function getActiveVerificationTaskId( dbOrTx: DatabaseOrTransaction, @@ -855,10 +861,17 @@ async function getActiveVerificationTaskId( const [job] = await dbOrTx .select({ taskId: taskRuns.taskId }) .from(taskRuns) + .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) .where( and( inArray(taskRuns.status, [...activeRunStatuses]), - sql`${taskRuns.payload} ->> 'verifiesEnvironmentId' = ${environmentId}`, + or( + sql`${taskRuns.payload} ->> 'verifiesEnvironmentId' = ${environmentId}`, + and( + eq(tasks.workflow, 'setup_onboarding'), + sql`${taskRuns.payload} ->> 'environmentDefinitionId' = ${environmentId}`, + ), + ), ), ) .orderBy(desc(taskRuns.createdAt), desc(taskRuns.id)) @@ -867,6 +880,19 @@ async function getActiveVerificationTaskId( return job?.taskId ?? null; } +/** + * Test-only export of the active-verification-attempt guard so its + * environment-marker matching (retry `verifiesEnvironmentId` and onboarding + * `setup_onboarding` + `environmentDefinitionId`) can be verified against a + * real database. + */ +export async function getActiveVerificationTaskIdForTest( + dbOrTx: DatabaseOrTransaction, + environmentId: string, +): Promise { + return getActiveVerificationTaskId(dbOrTx, environmentId); +} + /** * Re-run environment verification: enqueue a standard task that runs inside the * target environment, verifies it, and records the result through the From b31e14367c0d28dc21e95a692c87ad9ca9eadbf7 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 16:27:00 +0100 Subject: [PATCH 06/21] Handle env var follow-up reconnect failures --- .../PendingEnvVarRequestPanel.client.test.tsx | 30 ++++++++++++++----- .../[taskId]/PendingEnvVarRequestPanel.tsx | 29 ++++++++++++++---- 2 files changed, 46 insertions(+), 13 deletions(-) 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..6f7758c3 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 @@ -427,7 +427,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 +441,31 @@ 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, + }), + ); + 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..fe348ba1 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx @@ -210,16 +210,33 @@ 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, + ); + } + 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 From 7d1c35906c547c63e8996268d179678b883fe138 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 16:12:40 +0000 Subject: [PATCH 07/21] [Fix] Claim verification attempt before run becomes eligible; persist env-var fulfillment on follow-up failure --- .../PendingEnvVarRequestPanel.client.test.tsx | 15 +++ .../[taskId]/PendingEnvVarRequestPanel.tsx | 17 +++ .../trpc/commands/environments/index.test.ts | 43 ++++++-- .../src/trpc/commands/environments/index.ts | 57 ++++++---- .../task-env-var-requests/index.test.ts | 82 ++++++++++++++ .../commands/task-env-var-requests/index.ts | 104 ++++++++++++++---- apps/web/src/trpc/routers/_app.ts | 8 ++ 7 files changed, 271 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/trpc/commands/task-env-var-requests/index.test.ts 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 6f7758c3..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, + }, + }, }), })); @@ -466,6 +473,14 @@ describe('PendingEnvVarRequestPanel', () => { 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 fe348ba1..46324614 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx @@ -225,6 +225,23 @@ export function PendingEnvVarRequestPanel({ '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); diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index 13733774..2b128b03 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -170,10 +170,18 @@ describe('retryEnvironmentVerificationCommand', () => { beforeEach(() => { vi.clearAllMocks(); mockActiveVerificationRuns.length = 0; - mockEnqueueTask.mockResolvedValue({ - taskId: 'task-verify-1', - id: 'run-verify-1', - }); + // The command registers the attempt through enqueueTask's beforeEnqueue + // hook, so the mock must invoke it to exercise registration. + mockEnqueueTask.mockImplementation( + async ( + _input: unknown, + options?: { beforeEnqueue?: (taskRun: unknown) => Promise }, + ) => { + const taskRun = { taskId: 'task-verify-1', id: 'run-verify-1' }; + await options?.beforeEnqueue?.(taskRun); + return taskRun; + }, + ); }); function mockEnvironmentLookup() { @@ -205,6 +213,7 @@ describe('retryEnvironmentVerificationCommand', () => { }), workflow: 'standard', }), + expect.objectContaining({ beforeEnqueue: expect.any(Function) }), ); expect(mockBeginEnvironmentVerification).toHaveBeenCalledWith( expect.anything(), @@ -233,15 +242,25 @@ describe('retryEnvironmentVerificationCommand', () => { // Model the advisory lock's serialization: the first retry through the // critical section sees no active run and enqueues, and its registration - // makes the attempt active for the second retry. + // (via beforeEnqueue) makes the attempt active for the second retry. let attempt = 0; - mockEnqueueTask.mockImplementation(async () => { - attempt += 1; - // After the first enqueue+register, a subsequent critical section sees - // the active run and must reject before enqueueing again. - mockActiveVerificationRuns.push({ taskId: `task-verify-${attempt}` }); - return { taskId: `task-verify-${attempt}`, id: `run-verify-${attempt}` }; - }); + mockEnqueueTask.mockImplementation( + async ( + _input: unknown, + options?: { beforeEnqueue?: (taskRun: unknown) => Promise }, + ) => { + attempt += 1; + const taskRun = { + taskId: `task-verify-${attempt}`, + id: `run-verify-${attempt}`, + }; + await options?.beforeEnqueue?.(taskRun); + // After the first enqueue+register, a subsequent critical section sees + // the active run and must reject before enqueueing again. + mockActiveVerificationRuns.push({ taskId: `task-verify-${attempt}` }); + return taskRun; + }, + ); const first = await retryEnvironmentVerificationCommand(buildMockAuth(), { environmentId: 'env-1', diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 449b70ed..96c76531 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -903,7 +903,10 @@ export async function getActiveVerificationTaskIdForTest( * per environment by a `withEnvironmentVerificationRetryLock` advisory lock, so * two concurrent retries cannot both pass the check and enqueue duplicate * verification runs; the second waits for the first to commit and then sees the - * active attempt and is rejected. + * active attempt and is rejected. The attempt is registered in `enqueueTask`'s + * `beforeEnqueue` hook so the environment points at the new task id before the + * run becomes eligible to start, avoiding a race where the run's + * `record_verification` is rejected as a mismatched attempt. */ export async function retryEnvironmentVerificationCommand( auth: UserAuthSuccess, @@ -943,31 +946,37 @@ export async function retryEnvironmentVerificationCommand( throw new Error('This environment is already being verified.'); } - const launchResult = await enqueueTask({ - title: `Verify environment: ${environment.name}`, - task: { - type: TaskPayloadKind.StandardTask, - payload: { - repo: ALL_REPOSITORIES, - environmentId: environment.id, - verifiesEnvironmentId: environment.id, - description: prompt, + const launchResult = await enqueueTask( + { + title: `Verify environment: ${environment.name}`, + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: ALL_REPOSITORIES, + environmentId: environment.id, + verifiesEnvironmentId: environment.id, + description: prompt, + }, }, + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', }, - initiator: { kind: 'user', userId }, - workflow: 'standard', - surface: 'web', - trigger: 'manual', - }); - - // Reset verification state and point the environment at the new task so - // record_verification can only be applied by this attempt. Done inside the - // advisory-locked transaction so a concurrent retry blocks until this - // attempt is registered and then observes it as active. - await beginEnvironmentVerification(tx, { - environmentId: environment.id, - verificationTaskId: launchResult.taskId, - }); + { + // Claim this verification attempt before the run becomes eligible to + // start. `beforeEnqueue` runs while still inside the advisory-locked + // transaction and before the run is pushed onto the queue, so the + // controller cannot start the run (and have its record_verification + // rejected as a mismatched attempt) before its task id is registered. + beforeEnqueue: async (taskRun) => { + await beginEnvironmentVerification(tx, { + environmentId: environment.id, + verificationTaskId: taskRun.taskId, + }); + }, + }, + ); return { taskId: launchResult.taskId }; }); diff --git a/apps/web/src/trpc/commands/task-env-var-requests/index.test.ts b/apps/web/src/trpc/commands/task-env-var-requests/index.test.ts new file mode 100644 index 00000000..4660e945 --- /dev/null +++ b/apps/web/src/trpc/commands/task-env-var-requests/index.test.ts @@ -0,0 +1,82 @@ +import { + db, + eq, + runFactory, + taskFactory, + taskMessages, + userFactory, +} from '@roomote/db/server'; +import { TaskPayloadKind } from '@roomote/types'; + +import type { UserAuthSuccess } from '@/types'; + +import { markTaskEnvVarRequestFulfilledCommand } from './index'; + +function buildAdminAuth(userId: string): UserAuthSuccess { + return { + success: true, + userType: 'user', + userId, + name: 'Admin', + primaryEmail: 'admin@example.com', + isAdmin: true, + featureFlags: {} as UserAuthSuccess['featureFlags'], + anonymousAnalyticsEnabled: false, + resource: { + username: null, + fullName: 'Admin', + firstName: 'Admin', + lastName: null, + primaryEmailAddress: { + id: 'email-1', + emailAddress: 'admin@example.com', + }, + emailAddresses: [{ id: 'email-1', emailAddress: 'admin@example.com' }], + imageUrl: '', + createdAt: null, + }, + }; +} + +const CLIENT_MESSAGE_ID = 'env-var-request-fulfilled:test-marker'; + +describe('markTaskEnvVarRequestFulfilledCommand', () => { + it('persists a durable hidden fulfillment envelope for the active run', async () => { + const user = await userFactory.create(); + const task = await taskFactory.create({ workflow: 'standard' }); + const run = await runFactory.create({ + taskId: task.id, + payloadKind: TaskPayloadKind.StandardTask, + }); + + const result = await markTaskEnvVarRequestFulfilledCommand( + buildAdminAuth(user.id), + { taskId: task.id, clientMessageId: CLIENT_MESSAGE_ID }, + ); + + expect(result).toEqual({ recorded: true }); + + const messages = await db.query.taskMessages.findMany({ + where: eq(taskMessages.runId, run.id), + }); + const fulfillment = messages.find( + (message) => + (message.payload as { clientMessageId?: string } | null) + ?.clientMessageId === CLIENT_MESSAGE_ID, + ); + + expect(fulfillment).toBeDefined(); + }); + + it('returns recorded:false when the task has no run', async () => { + const result = await markTaskEnvVarRequestFulfilledCommand( + buildAdminAuth('user-fulfill-2'), + { + taskId: 'task-without-a-run', + clientMessageId: CLIENT_MESSAGE_ID, + }, + ); + + expect(result).toEqual({ recorded: false }); + }); +}); diff --git a/apps/web/src/trpc/commands/task-env-var-requests/index.ts b/apps/web/src/trpc/commands/task-env-var-requests/index.ts index 0129da71..b5d08bc2 100644 --- a/apps/web/src/trpc/commands/task-env-var-requests/index.ts +++ b/apps/web/src/trpc/commands/task-env-var-requests/index.ts @@ -46,6 +46,89 @@ function sortNames(values: readonly string[]): string[] { return [...values].sort((left, right) => left.localeCompare(right)); } +/** + * Persist a durable hidden fulfillment envelope so a reconnect or history + * refetch does not resurface an already-handled env-var request. Recording is + * idempotent on `clientMessageId`, so calling it again for the same + * fulfillment is safe. + */ +async function recordEnvVarFulfillmentEnvelope(params: { + runId: number; + taskId: string; + userId: string; + clientMessageId: string; +}): Promise { + await recordTaskMessageEnvelope({ + runId: params.runId, + taskId: params.taskId, + userId: params.userId, + envelope: { + ts: Date.now(), + eventType: ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + role: 'user', + protocol: ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, + contentBlocks: [], + metadata: { + source: 'task_env_var_request_fulfilled', + visibleInTranscript: false, + }, + payload: { + clientMessageId: params.clientMessageId, + visibleInTranscript: false, + prompt: [], + content: null, + }, + visibleInTranscript: false, + }, + }); +} + +export const markTaskEnvVarRequestFulfilledSchema = z.object({ + taskId: z.string(), + clientMessageId: z + .string() + .refine(isEnvVarRequestFulfillmentClientMessageId, { + message: 'clientMessageId must be an env-var fulfillment marker', + }), +}); + +/** + * Durably mark an env-var request as fulfilled for the task's active run. + * + * Used by the client when the follow-up prompt send fails after the variables + * were already saved: the values persisted, but no durable fulfillment + * envelope was recorded, so a reconnect would otherwise resurface the same + * request. This records that envelope so the request stays dismissed. + */ +export async function markTaskEnvVarRequestFulfilledCommand( + auth: UserAuthSuccess, + input: z.input, +) { + assertAdmin(auth); + + const { taskId, clientMessageId } = + markTaskEnvVarRequestFulfilledSchema.parse(input); + + const activeTaskRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.taskId, taskId), + orderBy: desc(taskRuns.createdAt), + columns: { id: true }, + }); + + if (!activeTaskRun) { + return { recorded: false as const }; + } + + await recordEnvVarFulfillmentEnvelope({ + runId: activeTaskRun.id, + taskId, + userId: auth.userId, + clientMessageId, + }); + + return { recorded: true as const }; +} + export async function fulfillTaskEnvVarRequestCommand( auth: UserAuthSuccess, input: z.input, @@ -136,28 +219,11 @@ export async function fulfillTaskEnvVarRequestCommand( }); if (result.runId !== null && !result.canReload) { - await recordTaskMessageEnvelope({ + await recordEnvVarFulfillmentEnvelope({ runId: result.runId, taskId, userId: auth.userId, - envelope: { - ts: Date.now(), - eventType: ACP_ENVELOPE_EVENT_TYPES.UserPrompt, - role: 'user', - protocol: ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, - contentBlocks: [], - metadata: { - source: 'task_env_var_request_fulfilled', - visibleInTranscript: false, - }, - payload: { - clientMessageId, - visibleInTranscript: false, - prompt: [], - content: null, - }, - visibleInTranscript: false, - }, + clientMessageId, }); } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 8517608a..4f844376 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -195,6 +195,8 @@ import { import { fulfillTaskEnvVarRequestCommand, fulfillTaskEnvVarRequestSchema, + markTaskEnvVarRequestFulfilledCommand, + markTaskEnvVarRequestFulfilledSchema, } from '../commands/task-env-var-requests'; import { getUsersOnlyForFilterCommand, @@ -1366,6 +1368,12 @@ export const appRouter = createRouter({ .mutation(({ ctx: { auth }, input }) => fulfillTaskEnvVarRequestCommand(auth, input), ), + + markFulfilled: protectedProcedure + .input(markTaskEnvVarRequestFulfilledSchema) + .mutation(({ ctx: { auth }, input }) => + markTaskEnvVarRequestFulfilledCommand(auth, input), + ), }), sandboxSession: createRouter({ From a8327487e1864568a1d16e7325dc20f5ce15b5df Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 15 Jul 2026 16:32:21 +0000 Subject: [PATCH 08/21] [Fix] Commit verification attempt claim atomically with run creation --- .../trpc/commands/environments/index.test.ts | 30 ++++++++++++++----- .../src/trpc/commands/environments/index.ts | 25 +++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index 2b128b03..4ff22ebd 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -170,15 +170,21 @@ describe('retryEnvironmentVerificationCommand', () => { beforeEach(() => { vi.clearAllMocks(); mockActiveVerificationRuns.length = 0; - // The command registers the attempt through enqueueTask's beforeEnqueue - // hook, so the mock must invoke it to exercise registration. + // The command registers the attempt through enqueueTask's + // afterCreateInTransaction hook, so the mock must invoke it (with a stub + // transaction) to exercise registration. mockEnqueueTask.mockImplementation( async ( _input: unknown, - options?: { beforeEnqueue?: (taskRun: unknown) => Promise }, + options?: { + afterCreateInTransaction?: ( + tx: unknown, + taskRun: unknown, + ) => Promise; + }, ) => { const taskRun = { taskId: 'task-verify-1', id: 'run-verify-1' }; - await options?.beforeEnqueue?.(taskRun); + await options?.afterCreateInTransaction?.({}, taskRun); return taskRun; }, ); @@ -213,7 +219,9 @@ describe('retryEnvironmentVerificationCommand', () => { }), workflow: 'standard', }), - expect.objectContaining({ beforeEnqueue: expect.any(Function) }), + expect.objectContaining({ + afterCreateInTransaction: expect.any(Function), + }), ); expect(mockBeginEnvironmentVerification).toHaveBeenCalledWith( expect.anything(), @@ -242,19 +250,25 @@ describe('retryEnvironmentVerificationCommand', () => { // Model the advisory lock's serialization: the first retry through the // critical section sees no active run and enqueues, and its registration - // (via beforeEnqueue) makes the attempt active for the second retry. + // (via afterCreateInTransaction) makes the attempt active for the second + // retry. let attempt = 0; mockEnqueueTask.mockImplementation( async ( _input: unknown, - options?: { beforeEnqueue?: (taskRun: unknown) => Promise }, + options?: { + afterCreateInTransaction?: ( + tx: unknown, + taskRun: unknown, + ) => Promise; + }, ) => { attempt += 1; const taskRun = { taskId: `task-verify-${attempt}`, id: `run-verify-${attempt}`, }; - await options?.beforeEnqueue?.(taskRun); + await options?.afterCreateInTransaction?.({}, taskRun); // After the first enqueue+register, a subsequent critical section sees // the active run and must reject before enqueueing again. mockActiveVerificationRuns.push({ taskId: `task-verify-${attempt}` }); diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 96c76531..b67136c2 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -904,9 +904,10 @@ export async function getActiveVerificationTaskIdForTest( * two concurrent retries cannot both pass the check and enqueue duplicate * verification runs; the second waits for the first to commit and then sees the * active attempt and is rejected. The attempt is registered in `enqueueTask`'s - * `beforeEnqueue` hook so the environment points at the new task id before the - * run becomes eligible to start, avoiding a race where the run's - * `record_verification` is rejected as a mismatched attempt. + * `afterCreateInTransaction` hook, so the environment's `verificationTaskId` + * commits atomically with the run row and before the run is queued — avoiding a + * race where the run's `record_verification` is rejected as a mismatched + * attempt because the claim had not committed yet. */ export async function retryEnvironmentVerificationCommand( auth: UserAuthSuccess, @@ -964,13 +965,17 @@ export async function retryEnvironmentVerificationCommand( trigger: 'manual', }, { - // Claim this verification attempt before the run becomes eligible to - // start. `beforeEnqueue` runs while still inside the advisory-locked - // transaction and before the run is pushed onto the queue, so the - // controller cannot start the run (and have its record_verification - // rejected as a mismatched attempt) before its task id is registered. - beforeEnqueue: async (taskRun) => { - await beginEnvironmentVerification(tx, { + // Claim this verification attempt inside enqueue's own run-creation + // transaction, so the task id and the environment's verificationTaskId + // commit atomically with the run row and before the run is pushed onto + // the controller queue. `beforeEnqueue` (or writing through the outer + // advisory-lock transaction) is not sufficient here: the run row is + // committed by enqueue's transaction before the outer transaction + // commits, so the controller could start the run and call + // record_verification before the claim is visible, yielding a + // mismatched-attempt rejection. + afterCreateInTransaction: async (enqueueTx, taskRun) => { + await beginEnvironmentVerification(enqueueTx, { environmentId: environment.id, verificationTaskId: taskRun.taskId, }); From eb3f067a1943b0d276f719d06f3a0a0feed72e3a Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 17:17:10 +0100 Subject: [PATCH 09/21] fallback --- .../[taskId]/ErrorFallback.client.test.tsx | 76 ++---------- .../(sandbox)/task/[taskId]/ErrorFallback.tsx | 116 +++--------------- .../[taskId]/PendingEnvVarRequestPanel.tsx | 2 +- .../src/components/ai-elements/todo-list.tsx | 2 +- 4 files changed, 34 insertions(+), 162 deletions(-) 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..789df0fe 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,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; const { invalidateQueriesMock, @@ -26,18 +25,6 @@ vi.mock('@/trpc/client', () => ({ vi.mock('@/components/system', () => ({ AlertCircle: () => , RefreshCw: () => , - Button: ({ - children, - ...props - }: { - children: ReactNode; - disabled?: boolean; - onClick?: () => void; - }) => ( - - ), })); vi.mock('./hooks', () => ({ @@ -142,7 +129,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 +156,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 +188,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,7 +198,7 @@ describe('ConnectionStatusBanner', () => { ).not.toBeInTheDocument(); }); - it('shows a backend-unavailable message when the initial connection retry budget is exhausted', () => { + it('keeps showing the automatic connection banner when initial retries are exhausted', () => { useSandboxConnectionStatusMock.mockReturnValue({ connected: false, hasConnectedOnce: false, @@ -234,11 +221,11 @@ describe('ConnectionStatusBanner', () => { ); expect( - screen.getByText('Could not reach the live task'), + screen.getByText('Trying to connect to the sandbox...'), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Try again' }), - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Try again' }), + ).not.toBeInTheDocument(); expect( screen.queryByText('Lost connection to the live task'), ).not.toBeInTheDocument(); @@ -403,45 +390,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 +446,7 @@ describe('ConnectionStatusBanner', () => { ).toBeInTheDocument(); }); - it('shows an auth-specific initial message for token/bootstrap failures', () => { + it('shows the automatic connection banner for token/bootstrap failures', () => { render( { ); expect( - screen.getByText('Could not verify access to the live task'), + screen.getByText('Trying to connect to the sandbox...'), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Try again' }), - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Try again' }), + ).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx index 0e225115..e2cb9519 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, 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,17 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const [retrying, setRetrying] = useState(false); - const { connected, hasConnectedOnce, connectionError, connectionFailureCategory, reconnecting, - reconnect, } = useSandboxConnectionStatus(); const effectiveFailureCategory = connectionFailureCategory ?? session.transportErrorCategory; - const showReconnectState = reconnecting || retrying; + const showReconnectState = reconnecting; const showError = !showReconnectState && (connectionError || session.hasTransportError); const showInitialConnectionState = @@ -99,18 +73,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 +90,13 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { return null; } - if (showInitialConnectionState) { - return ( -
- - - {getInitialConnectionMessage({ - showReconnectState, - connectionFailureCategory: effectiveFailureCategory, - })} - - {!showReconnectState ? ( - - ) : null} -
- ); - } - - if (showReconnectState) { + if (showInitialConnectionState || showReconnectState) { return ( -
- - - Reconnecting to the live task... - +
+
+ + Trying to connect to the sandbox... +
); } @@ -172,27 +106,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]/PendingEnvVarRequestPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx index 46324614..d026fc1a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx @@ -264,7 +264,7 @@ export function PendingEnvVarRequestPanel({ }; return ( -
+
diff --git a/apps/web/src/components/ai-elements/todo-list.tsx b/apps/web/src/components/ai-elements/todo-list.tsx index 14b19c00..ed4bb268 100644 --- a/apps/web/src/components/ai-elements/todo-list.tsx +++ b/apps/web/src/components/ai-elements/todo-list.tsx @@ -32,7 +32,7 @@ export const TodoListItem = ({
  • Date: Wed, 15 Jul 2026 17:49:59 +0100 Subject: [PATCH 10/21] Clarify task session load failures --- .../[taskId]/hooks/task-notification-phase.ts | 3 +- .../task/[taskId]/hooks/use-task-session.ts | 6 ++-- .../src/app/(sandbox)/task/[taskId]/page.tsx | 30 ++++++++++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) 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..43c3183e 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[] = []; @@ -157,7 +157,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.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index 52980a1b..28cdf6ae 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, @@ -41,6 +41,7 @@ import { Header } from './Header'; import { HistoricalContent } from './HistoricalContent'; import { getTaskNotificationPhase } from './hooks/task-notification-phase'; import { MemoizedLiveContent } from './LiveContent'; +import { EnvironmentDefinitionAgentTaskPanel } from '@/components/settings/environments/EnvironmentDefinitionAgentTask'; export default function SandboxPage() { const { taskId: unresolvedTaskId } = useParams<{ taskId: string }>(); @@ -185,6 +186,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 +212,20 @@ export default function SandboxPage() { ); } + if (task?.workflow === 'setup_onboarding') { + return ( + +
    + +
    +
    + ); + } + if (sessionState === 'historical' || sessionState === 'resuming') { return ( Date: Wed, 15 Jul 2026 17:56:01 +0100 Subject: [PATCH 11/21] Handle paused environment setup sessions --- .../task/[taskId]/page.client.test.tsx | 55 +++++++++++++++++++ .../src/app/(sandbox)/task/[taskId]/page.tsx | 23 ++++++-- .../commands/sandbox-session/index.test.ts | 30 ++++++++++ .../trpc/commands/sandbox-session/index.ts | 1 + .../commands/sandbox-session/session-state.ts | 16 +++++- 5 files changed, 119 insertions(+), 6 deletions(-) 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..0676c0b0 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 @@ -114,6 +114,15 @@ vi.mock('./LiveContent', () => ({ MemoizedLiveContent: () =>
    , })); +vi.mock( + '@/components/settings/environments/EnvironmentDefinitionAgentTask', + () => ({ + EnvironmentDefinitionAgentTaskPanel: () => ( +
    + ), + }), +); + import SandboxPage from './page'; function renderPage() { @@ -199,6 +208,52 @@ 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 setup onboarding tasks with the environment definition panel', () => { + useTaskSessionMock.mockReturnValue({ + ...baseSession, + sessionState: 'historical', + task: { + ...baseSession.task, + workflow: 'setup_onboarding', + }, + taskRun: { + ...baseSession.taskRun, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + }, + }); + useTaskMessageEnvelopesMock.mockReturnValue({ + data: [], + }); + + renderPage(); + + expect( + screen.getByTestId('environment-definition-agent-task-panel'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('sandbox-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 28cdf6ae..63775450 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -212,6 +212,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 (task?.workflow === 'setup_onboarding') { return ( @@ -276,8 +289,8 @@ export default function SandboxPage() {
    {session.draftPrompt && ( @@ -292,8 +305,8 @@ export default function SandboxPage() {
    {session.draftPrompt && ( @@ -306,7 +319,7 @@ export default function SandboxPage() { return ( { }), ).toBe('interactive'); }); + + it('uses history for setup tasks paused while waiting for environment variables', () => { + const taskRun = createTaskRunDetail({ + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + }); + + expect( + getSessionState(taskRun, { + hasMessages: false, + hasHarnessMessages: true, + taskWorkflow: 'setup_onboarding', + }), + ).toBe('historical'); + }); + + it('keeps ordinary idle tasks interactive while waiting for a prompt', () => { + const taskRun = createTaskRunDetail({ + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + }); + + expect( + getSessionState(taskRun, { + hasMessages: false, + hasHarnessMessages: true, + taskWorkflow: 'standard', + }), + ).toBe('interactive'); + }); }); describe('isWaitingForFirstHarnessMessage', () => { diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index 52bbb1df..bb94e638 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -644,6 +644,7 @@ export async function getSandboxSessionByTaskIdCommand( const sessionState = getSessionState(taskRun, { hasMessages, hasHarnessMessages, + taskWorkflow: task.workflow, }); const resolvedPreviewRuntimeConfig = await resolveEffectivePreviewRuntimeConfig({ diff --git a/apps/web/src/trpc/commands/sandbox-session/session-state.ts b/apps/web/src/trpc/commands/sandbox-session/session-state.ts index a76c201f..9832419f 100644 --- a/apps/web/src/trpc/commands/sandbox-session/session-state.ts +++ b/apps/web/src/trpc/commands/sandbox-session/session-state.ts @@ -27,8 +27,14 @@ export function getSessionState( { hasMessages, hasHarnessMessages, + taskWorkflow, now = Date.now(), - }: { hasMessages: boolean; hasHarnessMessages: boolean; now?: number }, + }: { + hasMessages: boolean; + hasHarnessMessages: boolean; + taskWorkflow?: string | null; + now?: number; + }, ): SessionState { const taskRunStatus = taskRun.status; const taskRunError = getTaskRunError(taskRun); @@ -57,6 +63,14 @@ export function getSessionState( return 'booting'; } + if ( + taskWorkflow === 'setup_onboarding' && + taskRunStatus === RunStatus.Idle && + taskRun.taskPhase === 'waiting_for_prompt' + ) { + return 'historical'; + } + // Keep the startup/resume surface visible until the harness has produced at // least one non-user message. This avoids switching to the live chat view // while the agent is still warming up after the sandbox is ready. From 9f90fd633528f996e9b2c00ad285235b1e599550 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 18:22:19 +0100 Subject: [PATCH 12/21] Remove environment setup task panel --- ...SetupOnboardingAgentWidget.client.test.tsx | 26 +- .../setup/SetupOnboardingAgentWidget.tsx | 35 +- .../task/[taskId]/page.client.test.tsx | 18 +- .../src/app/(sandbox)/task/[taskId]/page.tsx | 25 +- ...ronmentDefinitionAgentTask.client.test.tsx | 571 +----------------- .../EnvironmentDefinitionAgentTask.tsx | 462 +------------- .../system/custom/states/StateContainer.tsx | 6 +- 7 files changed, 53 insertions(+), 1090 deletions(-) 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 5b248aa3..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'} -
    - ), }), ); @@ -63,7 +50,10 @@ vi.mock('@/components/system', () => ({ onClick?: () => void; asChild?: boolean; } & HTMLAttributes) => { - void asChild; + if (asChild) { + return <>{children}; + } + return ( + {succeeded ? ( -
    - ) : null} + ) : null} +
    ) : (
    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 0676c0b0..0faa28fe 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 @@ -114,15 +114,6 @@ vi.mock('./LiveContent', () => ({ MemoizedLiveContent: () =>
    , })); -vi.mock( - '@/components/settings/environments/EnvironmentDefinitionAgentTask', - () => ({ - EnvironmentDefinitionAgentTaskPanel: () => ( -
    - ), - }), -); - import SandboxPage from './page'; function renderPage() { @@ -228,7 +219,7 @@ describe('SandboxPage', () => { expect(screen.queryByTestId('sandbox-provider')).not.toBeInTheDocument(); }); - it('renders setup onboarding tasks with the environment definition panel', () => { + it('renders paused setup onboarding tasks with the live task surface', () => { useTaskSessionMock.mockReturnValue({ ...baseSession, sessionState: 'historical', @@ -248,10 +239,9 @@ describe('SandboxPage', () => { renderPage(); - expect( - screen.getByTestId('environment-definition-agent-task-panel'), - ).toBeInTheDocument(); - expect(screen.queryByTestId('sandbox-provider')).not.toBeInTheDocument(); + 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', () => { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index 63775450..2464fb78 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -14,6 +14,7 @@ import { CircleSlash, TriangleAlert } from '@/components/system'; import { TaskPayloadKind, DEFAULT_CODING_HARNESS, + RunStatus, isExitedRunStatus, type TaskPhase, } from '@roomote/types'; @@ -41,7 +42,6 @@ import { Header } from './Header'; import { HistoricalContent } from './HistoricalContent'; import { getTaskNotificationPhase } from './hooks/task-notification-phase'; import { MemoizedLiveContent } from './LiveContent'; -import { EnvironmentDefinitionAgentTaskPanel } from '@/components/settings/environments/EnvironmentDefinitionAgentTask'; export default function SandboxPage() { const { taskId: unresolvedTaskId } = useParams<{ taskId: string }>(); @@ -77,6 +77,10 @@ export default function SandboxPage() { taskRun?.payloadKind === TaskPayloadKind.SnapshotResume && sessionState === 'boot-failed' && (hasTranscriptHistory || hasArtifacts || hasVisibleSessionPrompt); + const shouldRenderLiveSetupOnboarding = + task?.workflow === 'setup_onboarding' && + taskRun?.status === RunStatus.Idle && + taskRun.taskPhase === 'waiting_for_prompt'; const handleBootStatusChange = useCallback(() => { queryClient.invalidateQueries({ queryKey: trpc.sandboxSession.byTaskId.queryKey(), @@ -225,21 +229,10 @@ export default function SandboxPage() { ); } - if (task?.workflow === 'setup_onboarding') { - return ( - -
    - -
    -
    - ); - } - - if (sessionState === 'historical' || sessionState === 'resuming') { + if ( + (sessionState === 'historical' || sessionState === 'resuming') && + !shouldRenderLiveSetupOnboarding + ) { 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 b44b4111..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; @@ -234,426 +199,3 @@ export function useEnvironmentDefinitionAgentState({ 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/system/custom/states/StateContainer.tsx b/apps/web/src/components/system/custom/states/StateContainer.tsx index 758bb522..6ae19133 100644 --- a/apps/web/src/components/system/custom/states/StateContainer.tsx +++ b/apps/web/src/components/system/custom/states/StateContainer.tsx @@ -27,10 +27,12 @@ export function StateContainer({ )}
    {title && ( -
    {title}
    +
    + {title} +
    )} {description && ( -
    {description}
    +
    {description}
    )}
    From 411ab36978b602fc4abb954809e0d7ba963f183f Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 18:28:41 +0100 Subject: [PATCH 13/21] Keep paused setup tasks interactive --- .../app/(sandbox)/task/[taskId]/page.client.test.tsx | 2 +- apps/web/src/app/(sandbox)/task/[taskId]/page.tsx | 10 +--------- .../src/trpc/commands/sandbox-session/index.test.ts | 6 ++---- apps/web/src/trpc/commands/sandbox-session/index.ts | 1 - .../src/trpc/commands/sandbox-session/session-state.ts | 10 ---------- 5 files changed, 4 insertions(+), 25 deletions(-) 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 0faa28fe..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 @@ -222,7 +222,7 @@ describe('SandboxPage', () => { it('renders paused setup onboarding tasks with the live task surface', () => { useTaskSessionMock.mockReturnValue({ ...baseSession, - sessionState: 'historical', + sessionState: 'interactive', task: { ...baseSession.task, workflow: 'setup_onboarding', diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index 2464fb78..fe444136 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -14,7 +14,6 @@ import { CircleSlash, TriangleAlert } from '@/components/system'; import { TaskPayloadKind, DEFAULT_CODING_HARNESS, - RunStatus, isExitedRunStatus, type TaskPhase, } from '@roomote/types'; @@ -77,10 +76,6 @@ export default function SandboxPage() { taskRun?.payloadKind === TaskPayloadKind.SnapshotResume && sessionState === 'boot-failed' && (hasTranscriptHistory || hasArtifacts || hasVisibleSessionPrompt); - const shouldRenderLiveSetupOnboarding = - task?.workflow === 'setup_onboarding' && - taskRun?.status === RunStatus.Idle && - taskRun.taskPhase === 'waiting_for_prompt'; const handleBootStatusChange = useCallback(() => { queryClient.invalidateQueries({ queryKey: trpc.sandboxSession.byTaskId.queryKey(), @@ -229,10 +224,7 @@ export default function SandboxPage() { ); } - if ( - (sessionState === 'historical' || sessionState === 'resuming') && - !shouldRenderLiveSetupOnboarding - ) { + if (sessionState === 'historical' || sessionState === 'resuming') { return ( { ).toBe('interactive'); }); - it('uses history for setup tasks paused while waiting for environment variables', () => { + it('keeps setup tasks interactive while waiting for environment variables', () => { const taskRun = createTaskRunDetail({ status: RunStatus.Idle, taskPhase: 'waiting_for_prompt', @@ -210,9 +210,8 @@ describe('getSessionState', () => { getSessionState(taskRun, { hasMessages: false, hasHarnessMessages: true, - taskWorkflow: 'setup_onboarding', }), - ).toBe('historical'); + ).toBe('interactive'); }); it('keeps ordinary idle tasks interactive while waiting for a prompt', () => { @@ -225,7 +224,6 @@ describe('getSessionState', () => { getSessionState(taskRun, { hasMessages: false, hasHarnessMessages: true, - taskWorkflow: 'standard', }), ).toBe('interactive'); }); diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index bb94e638..52bbb1df 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -644,7 +644,6 @@ export async function getSandboxSessionByTaskIdCommand( const sessionState = getSessionState(taskRun, { hasMessages, hasHarnessMessages, - taskWorkflow: task.workflow, }); const resolvedPreviewRuntimeConfig = await resolveEffectivePreviewRuntimeConfig({ diff --git a/apps/web/src/trpc/commands/sandbox-session/session-state.ts b/apps/web/src/trpc/commands/sandbox-session/session-state.ts index 9832419f..e50c1ca5 100644 --- a/apps/web/src/trpc/commands/sandbox-session/session-state.ts +++ b/apps/web/src/trpc/commands/sandbox-session/session-state.ts @@ -27,12 +27,10 @@ export function getSessionState( { hasMessages, hasHarnessMessages, - taskWorkflow, now = Date.now(), }: { hasMessages: boolean; hasHarnessMessages: boolean; - taskWorkflow?: string | null; now?: number; }, ): SessionState { @@ -63,14 +61,6 @@ export function getSessionState( return 'booting'; } - if ( - taskWorkflow === 'setup_onboarding' && - taskRunStatus === RunStatus.Idle && - taskRun.taskPhase === 'waiting_for_prompt' - ) { - return 'historical'; - } - // Keep the startup/resume surface visible until the harness has produced at // least one non-user message. This avoids switching to the live chat view // while the agent is still warming up after the sandbox is ready. From 8ab1e9a8966ebb5b64f563d62fc7d590f2702d47 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 19:14:27 +0100 Subject: [PATCH 14/21] Fix preview proxy dev logger crash --- apps/preview-proxy/src/lib/logger.ts | 29 +++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) 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 ( From 040bbe7f5c289b46785c60c7922570a09e3809fd Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 19:45:53 +0100 Subject: [PATCH 15/21] Improvements to environment display in settings --- .../EnvironmentVerificationStatus.tsx | 145 ++++++++---------- .../environments/Environments.client.test.tsx | 18 ++- .../settings/environments/Environments.tsx | 74 +++++---- .../components/system/primitives/badge.tsx | 8 +- .../src/components/system/primitives/icons.ts | 1 + 5 files changed, 127 insertions(+), 119 deletions(-) diff --git a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx index ee123884..f0331795 100644 --- a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx @@ -1,19 +1,22 @@ '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 { useRetryEnvironmentVerification } from '@/hooks/environments'; import { AlertCircle, Badge, BasicTooltip, - Button, + Check, CheckCircle2, Loader2, - RefreshCw, + X, } from '@/components/system'; +import { ArrowUpRight, CircleQuestionMark, ExternalLink } from 'lucide-react'; +import { Arrow } from '@radix-ui/react-tooltip'; +import { ArrowTopRightIcon } from '@radix-ui/react-icons'; type EnvironmentVerificationState = | 'verified' @@ -21,7 +24,7 @@ type EnvironmentVerificationState = | 'failed' | 'configured'; -function getEnvironmentVerificationState( +export function getEnvironmentVerificationState( environment: Pick< EnvironmentWithMeta, | 'isVerified' @@ -48,8 +51,27 @@ function getEnvironmentVerificationState( return 'configured'; } -function VerificationBadge({ env }: { env: EnvironmentWithMeta }) { +export function EnvironmentVerificationBadge({ + env, +}: { + env: EnvironmentWithMeta; +}) { const state = getEnvironmentVerificationState(env); + const badgeClassName = env.verificationTaskId + ? 'gap-1 cursor-pointer' + : 'gap-1'; + const wrapBadge = (badge: ReactNode) => + env.verificationTaskId ? ( + + {badge} + + ) : ( + badge + ); switch (state) { case 'verified': @@ -57,7 +79,10 @@ function VerificationBadge({ env }: { env: EnvironmentWithMeta }) { -

    Your environment is verified and ready to use.

    +

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

    {env.verifiedAt ? (

    Verified{' '} @@ -67,10 +92,13 @@ function VerificationBadge({ env }: { env: EnvironmentWithMeta }) {

    } > - - - Verified - + {wrapBadge( + + + Verified + + , + )} ); case 'in_progress': @@ -83,10 +111,13 @@ function VerificationBadge({ env }: { env: EnvironmentWithMeta }) {
    } > - - - Verification in progress - + {wrapBadge( + + + Verification in progress + + , + )} ); case 'failed': @@ -96,13 +127,19 @@ function VerificationBadge({ env }: { env: EnvironmentWithMeta }) {
    Roomote could not verify that this environment works. It is still usable; retry verification after checking the configuration. + {env.verificationError ? ( +

    {env.verificationError}

    + ) : null}
    } > - - - Verification failed - + {wrapBadge( + + + Verification failed + + , + )} ); case 'configured': @@ -110,72 +147,20 @@ function VerificationBadge({ env }: { env: EnvironmentWithMeta }) { return ( - This environment is configured but has not been verified for its - current configuration. +
    + {env.name} is configured but has not been verified for its current + configuration. Click to see the verification task.
    } > - - Configured - + {wrapBadge( + + + Configured + + , + )}
    ); } } - -/** - * Renders the verification status for an environment, its latest failure - * message when present, a link to the related verification task, and a Retry - * verification action. - */ -export function EnvironmentVerificationStatus({ - env, -}: { - env: EnvironmentWithMeta; -}) { - const retryVerification = useRetryEnvironmentVerification(); - const state = getEnvironmentVerificationState(env); - const isRetrying = - retryVerification.isPending && - retryVerification.variables?.environmentId === env.id; - const inProgress = state === 'in_progress'; - - return ( -
    -
    - Verification · - - {env.verificationTaskId ? ( - - View task - - ) : null} - {!inProgress ? ( - - ) : null} -
    - {state === 'failed' && env.verificationError ? ( -

    - {env.verificationError} -

    - ) : 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 e54b55bb..783c1eb0 100644 --- a/apps/web/src/components/settings/environments/Environments.client.test.tsx +++ b/apps/web/src/components/settings/environments/Environments.client.test.tsx @@ -315,6 +315,7 @@ vi.mock('@/components/system', () => ({ Pencil: Icon, Plus: Icon, RefreshCw: Icon, + SearchCheck: Icon, Popover: ({ children }: { children: ReactNode }) =>
    {children}
    , PopoverContent: ({ children, @@ -431,25 +432,30 @@ 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(); }); diff --git a/apps/web/src/components/settings/environments/Environments.tsx b/apps/web/src/components/settings/environments/Environments.tsx index 843faf1b..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,7 +51,10 @@ import { Section } from '@/components/settings'; import { DuplicateEnvironmentDialog } from './DuplicateEnvironmentDialog'; import { SnapshotStatusBadge } from './SnapshotStatusBadge'; -import { EnvironmentVerificationStatus } from './EnvironmentVerificationStatus'; +import { + EnvironmentVerificationBadge, + getEnvironmentVerificationState, +} from './EnvironmentVerificationStatus'; function getEnvironmentSnapshot( environment: EnvironmentWithMeta, @@ -58,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(); @@ -70,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']; @@ -124,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 ? ( @@ -143,11 +148,20 @@ export function Environments() { ) : null}
    - {description ? ( -

    - {description} -

    - ) : null} +
    + + {env.config.repositories?.map((repo, idx) => ( +
    + + + {repo.repository} + +
    + ))} +
    @@ -166,6 +180,26 @@ export function Environments() { + {verificationState !== 'in_progress' ? ( + + ) : null} ), 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.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx index d026fc1a..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({}); 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/use-task-session.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts index 43c3183e..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 @@ -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; diff --git a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx index f0331795..e34f4436 100644 --- a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx @@ -6,24 +6,49 @@ import { formatDistanceToNow } from 'date-fns'; import type { EnvironmentWithMeta } from '@/trpc/commands/environments'; import { - AlertCircle, + ArrowUpRightIcon, Badge, BasicTooltip, Check, - CheckCircle2, + HelpCircle, Loader2, + type LucideIcon, X, } from '@/components/system'; -import { ArrowUpRight, CircleQuestionMark, ExternalLink } from 'lucide-react'; -import { Arrow } from '@radix-ui/react-tooltip'; -import { ArrowTopRightIcon } from '@radix-ui/react-icons'; -type EnvironmentVerificationState = +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, @@ -57,6 +82,7 @@ export function EnvironmentVerificationBadge({ env: EnvironmentWithMeta; }) { const state = getEnvironmentVerificationState(env); + const { Icon, iconClassName, label } = environmentVerificationDisplay[state]; const badgeClassName = env.verificationTaskId ? 'gap-1 cursor-pointer' : 'gap-1'; @@ -94,9 +120,11 @@ export function EnvironmentVerificationBadge({ > {wrapBadge( - - Verified - + + {label} + , )} @@ -113,9 +141,11 @@ export function EnvironmentVerificationBadge({ > {wrapBadge( - - Verification in progress - + + {label} + , )} @@ -124,7 +154,7 @@ export function EnvironmentVerificationBadge({ return ( +
    Roomote could not verify that this environment works. It is still usable; retry verification after checking the configuration. {env.verificationError ? ( @@ -135,9 +165,11 @@ export function EnvironmentVerificationBadge({ > {wrapBadge( - - Verification failed - + + {label} + , )} @@ -155,9 +187,11 @@ export function EnvironmentVerificationBadge({ > {wrapBadge( - - Configured - + + {label} + , )} diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index 52bbb1df..a3389317 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -1,6 +1,7 @@ import { RunStatus, TaskPayloadKind, + activeRunStatuses, getEnvironmentDefinitionIdFromPayload, isBootingRunStatus, isExitedRunStatus, @@ -54,6 +55,7 @@ import { const SANDBOX_PROMPT_TOKEN_TIMEOUT_MS = 15 * 60 * 1000; const SANDBOX_PROMPT_TIMEOUT_MS = 30_000; const SANDBOX_RPC_HEALTHCHECK_TIMEOUT_MS = 5_000; +const activeVerificationTaskStatuses: readonly RunStatus[] = activeRunStatuses; const requestUserInputAnswersSchema = z.record( z.object({ answers: z.array(z.string()), @@ -602,11 +604,36 @@ export async function getSandboxSessionByTaskIdCommand( onboardingEnvironmentId ? db.query.environments.findFirst({ where: eq(environments.id, onboardingEnvironmentId), - columns: { name: true }, + columns: { + name: true, + isVerified: true, + verificationTaskId: true, + verifiedAt: true, + verificationError: true, + }, }) : Promise.resolve(null), ]); + const activeVerificationTaskRun = onboardingEnvironment?.verificationTaskId + ? await db.query.taskRuns.findFirst({ + where: eq(taskRuns.taskId, onboardingEnvironment.verificationTaskId), + columns: { status: true }, + }) + : null; + + const onboardingEnvironmentWithVerification = onboardingEnvironment + ? { + ...onboardingEnvironment, + verificationTaskActive: + activeVerificationTaskRun !== null && + activeVerificationTaskRun !== undefined && + activeVerificationTaskStatuses.includes( + activeVerificationTaskRun.status, + ), + } + : null; + const shouldCheckBootFailureMessages = taskRun.status === RunStatus.Failed || taskRun.status === RunStatus.Canceled; @@ -690,7 +717,7 @@ export async function getSandboxSessionByTaskIdCommand( }, prompt, artifacts, - onboardingEnvironment, + onboardingEnvironment: onboardingEnvironmentWithVerification, sessionState, refetchInterval, }; diff --git a/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts index 7bfca35e..753b820b 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/environmentSetupSkill.test.ts @@ -174,7 +174,10 @@ describe('environment-setup guidance', () => { 'First read .roomote/setup-status.json in the workspace root: while its state is "running", environment setup commands are still executing in the background', ); expect(skillContent).toContain( - "If any setup command failed, report each failing command's name and exit code from .roomote/setup-status.json plus the relevant error lines from its log under .roomote/setup-logs/.", + 'Re-read .roomote/setup-status.json every 10-15 seconds while it is still running, rather than sleeping for several minutes at a time.', + ); + expect(skillContent).toContain( + "If any setup command failed, report each failing command's name and exit code from .roomote/setup-status.json plus the relevant error lines from .roomote/setup-logs/.", ); expect(skillContent).toContain( 'the platform delivers a `Spawned task update` message into this session when the verification task settles', @@ -183,11 +186,14 @@ describe('environment-setup guidance', () => { 'Treat that message as the primary completion signal', ); expect(skillContent).toContain( - 'While waiting for that settle notification, periodically check the verification task with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId` as a fallback signal.', + 'While waiting for that settle notification, check the verification task every 10-15 seconds with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId` as a fallback signal.', ); expect(skillContent).toContain( 'treat `failed` or `completed with warnings` as direct evidence that specific setup commands failed even when the verification task has not described the failure yet', ); + expect(skillContent).toContain( + 'when `Environment Setup` is `completed` immediately inspect the latest task messages instead of sleeping for another long interval', + ); expect(skillContent).toContain( 'relaunch the verification task with `notifyOnSettle: true`, and wait for the new settle notification', ); @@ -195,7 +201,7 @@ describe('environment-setup guidance', () => { 'Narrate concise, plain-language progress updates while the follow-up check runs', ); expect(skillContent).toContain( - 'Preparing the environment can take 5 minutes or more, so do not stop monitoring just because startup is taking a long time.', + 'Preparing the environment can take several minutes, so do not stop monitoring just because startup is taking a long time; keep checks frequent enough that a completed setup or completed verification is noticed promptly.', ); expect(skillContent).toContain( 'If the monitored summary reaches `Ready`, `Idle`, or `Needs input`, do not keep polling that same state indefinitely.', diff --git a/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md b/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md index 1bb8b7b9..e85b798e 100644 --- a/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md +++ b/packages/cloud-agents/src/server/workflows/skills/standard/environment-setup/SKILL.md @@ -133,12 +133,12 @@ You are an expert Roomote environment analyst. Analyze the already-checked-out r 23. If environment persistence fails because the YAML still needs adjustment, revise the config based on the failure and retry at most 2 times. 24. After environment persistence succeeds, use the Roomote MCP task tools to launch one lightweight follow-up verification task against that environment before finishing this setup task. 25. For that follow-up task launch, call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "list_environments"` first so you can confirm the created or updated environment appears as a current launch target and copy the exact returned `environmentId`. -26. Then call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "launch"`, `environmentId` set to that created or updated environment ID, `notifyOnSettle` set to `true`, and a concrete prompt such as `Confirm that this environment is running correctly. First read .roomote/setup-status.json in the workspace root: while its state is "running", environment setup commands are still executing in the background — wait for it to reach a terminal state (you will get an in-session notification when it settles) instead of concluding startup is stuck; setup can take 5 minutes or more. Once setup has settled, use localhost or the environment's initial URL to verify the expected service responds successfully and confirm there are no obvious startup failures blocking basic use. If any setup command failed, report each failing command's name and exit code from .roomote/setup-status.json plus the relevant error lines from its log under .roomote/setup-logs/. Report the exact step that fails plus any visible error messages or logs. If everything works, say that the environment looks ready.`. +26. Then call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "launch"`, `environmentId` set to that created or updated environment ID, `notifyOnSettle` set to `true`, and a concrete prompt such as `Confirm that this environment is running correctly. First read .roomote/setup-status.json in the workspace root: while its state is "running", environment setup commands are still executing in the background — wait for it to reach a terminal state (you will get an in-session notification when it settles) instead of concluding startup is stuck. Re-read .roomote/setup-status.json every 10-15 seconds while it is still running, rather than sleeping for several minutes at a time. Once setup has settled, use localhost or the environment's initial URL to verify the expected service responds successfully and confirm there are no obvious startup failures blocking basic use. If any setup command failed, report each failing command's name and exit code from .roomote/setup-status.json plus the relevant error lines from .roomote/setup-logs/. Report the exact step that fails plus any visible error messages or logs. If everything works, say that the environment looks ready.`. 27. Keep the returned `taskId` for internal monitoring only. Do not expose the spawned verification task link in the user-facing response. 27a. Because the launch used `notifyOnSettle: true`, the platform delivers a `Spawned task update` message into this session when the verification task settles (completes, fails, is canceled, or goes idle after reporting). Treat that message as the primary completion signal: it is safe to finish other pending work or wait between summary checks, because the settle notification arrives even if this task has gone idle. When it arrives, immediately fetch the verification outcome with `action: "get_summary"` and `action: "get_messages"` and continue the workflow from step 31. -28. While waiting for that settle notification, periodically check the verification task with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId` as a fallback signal. Use that per-task summary as the source of truth for task state, including any surfaced startup or runtime failure details and the `Environment Setup` line, which reports the ground-truth lifecycle of the verification task's environment setup commands: treat `still running in the background` as normal active startup rather than a stall, and treat `failed` or `completed with warnings` as direct evidence that specific setup commands failed even when the verification task has not described the failure yet. +28. While waiting for that settle notification, check the verification task every 10-15 seconds with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and the returned `taskId` as a fallback signal. Use that per-task summary as the source of truth for task state, including any surfaced startup or runtime failure details and the `Environment Setup` line, which reports the ground-truth lifecycle of the verification task's environment setup commands: treat `still running in the background` as normal active startup rather than a stall, treat `failed` or `completed with warnings` as direct evidence that specific setup commands failed even when the verification task has not described the failure yet, and when `Environment Setup` is `completed` immediately inspect the latest task messages instead of sleeping for another long interval. 29. Narrate concise, plain-language progress updates while the follow-up check runs, so the user can see that setup is still being checked without being asked to wait. Say what is being checked (for example, `I'm confirming the environment starts cleanly`) rather than mentioning a spawned task, task status, polling, or monitoring. -30. Continue checking the verification task while the summary shows an active startup or running state, or until it clearly reports a startup or runtime blocker through the summary. Preparing the environment can take 5 minutes or more, so do not stop monitoring just because startup is taking a long time. +30. Continue checking the verification task while the summary shows an active startup or running state, or until it clearly reports a startup or runtime blocker through the summary. Preparing the environment can take several minutes, so do not stop monitoring just because startup is taking a long time; keep checks frequent enough that a completed setup or completed verification is noticed promptly. 31. If the monitored summary reaches `Completed` without a surfaced startup or runtime failure, treat that as a successful spawned-task run and report that observed outcome directly instead of asking the user to confirm it manually. 32. If the monitored summary reaches `Ready`, `Idle`, or `Needs input`, do not keep polling that same state indefinitely. Inspect the latest task messages to determine whether the verification task already reported success, surfaced a blocker, or is unexpectedly waiting for follow-up input. 33. If those latest task messages clearly report that the environment looks ready, treat that as a successful spawned-task run and report the observed success directly. @@ -335,9 +335,9 @@ You are an expert Roomote environment analyst. Analyze the already-checked-out r When the task explicitly identifies an existing environment to revise, update that environment instead of creating a duplicate. After successful environment persistence, use the Roomote MCP tool `mcp__roomote__manage_tasks` to launch a lightweight verification task against the created or updated environment and monitor it yourself instead of leaving verification as an implicit manual next step. Before launching that verification task, call the Roomote MCP tool `mcp__roomote__manage_tasks` with `action: "list_environments"` so the environment target is grounded in current Roomote data and you can copy the exact returned `environmentId`. -When the verification task launch succeeds, monitor it with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and use that per-task summary surface as the source of truth for task status and surfaced startup failures. +When the verification task launch succeeds, monitor it every 10-15 seconds with the Roomote MCP tool `mcp__roomote__manage_tasks` using `action: "get_summary"` and use that per-task summary surface as the source of truth for task status and surfaced startup failures. While the follow-up check runs, narrate concise progress updates in plain language instead of silently waiting or pushing the waiting back onto the user. Do not mention a spawned task, task status, polling, or monitoring in those user-facing updates. -Preparing the environment can take 5 minutes or more. Do not stop monitoring solely because the verification task is taking a long time to start; keep checking until it reaches a terminal state or surfaces a blocker you can report or act on. +Preparing the environment can take several minutes. Do not stop monitoring solely because the verification task is taking a long time to start; keep checking until it reaches a terminal state or surfaces a blocker you can report or act on, and keep checks frequent enough that a completed setup or completed verification is noticed promptly. If the monitored summary settles into `Ready`, `Idle`, or `Needs input`, inspect the latest task messages instead of polling that state forever, and only treat it as success when those messages clearly report that the environment looks ready. Record the verification outcome explicitly through the Roomote MCP tool `mcp__roomote__manage_environments` with `action: "record_verification"` and the created or updated `environmentId`: `success: true` only when the explicit "environment looks ready" criterion is satisfied, and `success: false` with a short, user-safe `error` when verification reaches a terminal failure or cannot be completed. Do not include secrets or the full environment YAML in the `error`, and do not pass a task id (Roomote derives the recording task from the current run). Treat a rejected `record_verification` result as a superseded attempt: do not retry recording, because a newer verification attempt or a runtime-affecting edit now owns the environment's verification state. From f3209afb9b436501383261112a6438f3008ee210 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 19:56:56 +0100 Subject: [PATCH 17/21] fix: remove unused error fallback import --- apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx index e2cb9519..6a9b9a83 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { AlertCircle, RefreshCw, Spinner } from '@/components/system'; +import { AlertCircle, Spinner } from '@/components/system'; import { useTRPC } from '@/trpc/client'; import { type TaskSession, useSandboxConnectionStatus } from './hooks'; From 46e013ce7ac147ae7d523029b5dfc192165f091e Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 20:03:07 +0100 Subject: [PATCH 18/21] test: update connection banner icon mock --- .../app/(sandbox)/task/[taskId]/ErrorFallback.client.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 789df0fe..3cc91d18 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 @@ -24,7 +24,7 @@ vi.mock('@/trpc/client', () => ({ vi.mock('@/components/system', () => ({ AlertCircle: () => , - RefreshCw: () => , + Spinner: () => , })); vi.mock('./hooks', () => ({ From 69633e3944d54e174906a9f65fdd08747ee9ac32 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 20:08:15 +0100 Subject: [PATCH 19/21] test: mock environment verification icons --- .../settings/environments/Environments.client.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 783c1eb0..d38c9640 100644 --- a/apps/web/src/components/settings/environments/Environments.client.test.tsx +++ b/apps/web/src/components/settings/environments/Environments.client.test.tsx @@ -162,6 +162,7 @@ vi.mock('@/components/system', () => ({ {children} ), BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}, + ArrowUpRightIcon: Icon, BookMarked: Icon, Button: ({ children, @@ -185,6 +186,7 @@ vi.mock('@/components/system', () => ({ ); }, Camera: Icon, + Check: Icon, CheckCircle2: Icon, ChevronDown: Icon, Collapsible: ({ @@ -304,6 +306,7 @@ vi.mock('@/components/system', () => ({

    {children}

    ), Github: Icon, + HelpCircle: Icon, Input: (props: InputHTMLAttributes) => , Label: ({ children, From 6efe15328e55284c4e8453f22d3a8c13f9d0706e Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 20:22:01 +0100 Subject: [PATCH 20/21] fix: address environment verification review feedback --- .../[taskId]/ErrorFallback.client.test.tsx | 32 +++++++++++-------- .../(sandbox)/task/[taskId]/ErrorFallback.tsx | 30 ++++++++++++++++- .../EnvironmentVerificationStatus.tsx | 26 ++++++++------- .../environments/Environments.client.test.tsx | 18 ++++++++++- .../components/system/primitives/badge.tsx | 2 +- .../trpc/commands/sandbox-session/index.ts | 14 ++++---- 6 files changed, 87 insertions(+), 35 deletions(-) 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 3cc91d18..ee7cdd2b 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,4 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import type { ButtonHTMLAttributes } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; const { invalidateQueriesMock, @@ -24,6 +25,10 @@ vi.mock('@/trpc/client', () => ({ vi.mock('@/components/system', () => ({ AlertCircle: () => , + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), + RefreshCw: () => , Spinner: () => , })); @@ -198,7 +203,8 @@ describe('ConnectionStatusBanner', () => { ).not.toBeInTheDocument(); }); - it('keeps showing the automatic connection banner when initial retries are exhausted', () => { + it('shows a retry action when initial retries are exhausted', () => { + const refreshConnection = vi.fn(); useSandboxConnectionStatusMock.mockReturnValue({ connected: false, hasConnectedOnce: false, @@ -214,18 +220,17 @@ describe('ConnectionStatusBanner', () => { { taskId: 'task-123', hasTransportError: false, - refreshConnection: vi.fn(), + refreshConnection, } as never } />, ); expect( - screen.getByText('Trying to connect to the sandbox...'), + screen.getByText('Could not reach the live task'), ).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Try again' }), - ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(refreshConnection).toHaveBeenCalledTimes(1); expect( screen.queryByText('Lost connection to the live task'), ).not.toBeInTheDocument(); @@ -446,7 +451,9 @@ describe('ConnectionStatusBanner', () => { ).toBeInTheDocument(); }); - it('shows the automatic connection banner for token/bootstrap failures', () => { + it('shows a retry action for token/bootstrap failures', () => { + const refreshConnection = vi.fn(); + render( { taskId: 'task-123', hasTransportError: true, transportErrorCategory: 'auth_error', - refreshConnection: vi.fn(), + refreshConnection, } as never } />, ); expect( - screen.getByText('Trying to connect to the sandbox...'), + screen.getByText('Could not verify access to the live task'), ).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Try again' }), - ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(refreshConnection).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx index 6a9b9a83..3e0ba578 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { AlertCircle, Spinner } from '@/components/system'; +import { AlertCircle, Button, RefreshCw, Spinner } from '@/components/system'; import { useTRPC } from '@/trpc/client'; import { type TaskSession, useSandboxConnectionStatus } from './hooks'; @@ -90,6 +90,34 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { return null; } + if (showInitialConnectionState && showError) { + return ( +
    +
    + + + {getErrorMessage({ + hasConnectedOnce, + connectionError, + connectionFailureCategory: effectiveFailureCategory, + })} + + +
    +
    + ); + } + if (showInitialConnectionState || showReconnectState) { return (
    diff --git a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx index e34f4436..6ad8974b 100644 --- a/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentVerificationStatus.tsx @@ -83,11 +83,10 @@ export function EnvironmentVerificationBadge({ }) { const state = getEnvironmentVerificationState(env); const { Icon, iconClassName, label } = environmentVerificationDisplay[state]; - const badgeClassName = env.verificationTaskId - ? 'gap-1 cursor-pointer' - : 'gap-1'; + const hasVerificationTask = Boolean(env.verificationTaskId); + const badgeClassName = hasVerificationTask ? 'gap-1 cursor-pointer' : 'gap-1'; const wrapBadge = (badge: ReactNode) => - env.verificationTaskId ? ( + hasVerificationTask ? (

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

    {env.verifiedAt ? (

    @@ -124,7 +125,7 @@ export function EnvironmentVerificationBadge({ className={['size-3', iconClassName].filter(Boolean).join(' ')} /> {label} - + {hasVerificationTask ? : null} , )} @@ -145,7 +146,7 @@ export function EnvironmentVerificationBadge({ className={['size-3', iconClassName].filter(Boolean).join(' ')} /> {label} - + {hasVerificationTask ? : null} , )} @@ -169,7 +170,7 @@ export function EnvironmentVerificationBadge({ className={['size-3', iconClassName].filter(Boolean).join(' ')} /> {label} - + {hasVerificationTask ? : null} , )} @@ -181,7 +182,10 @@ export function EnvironmentVerificationBadge({ content={

    {env.name} is configured but has not been verified for its current - configuration. Click to see the verification task. + configuration. + {hasVerificationTask + ? ' Click to see the verification task.' + : ''}
    } > @@ -191,7 +195,7 @@ export function EnvironmentVerificationBadge({ className={['size-3', iconClassName].filter(Boolean).join(' ')} /> {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 d38c9640..e14f0462 100644 --- a/apps/web/src/components/settings/environments/Environments.client.test.tsx +++ b/apps/web/src/components/settings/environments/Environments.client.test.tsx @@ -55,7 +55,7 @@ const state = vi.hoisted(() => ({ }, }, isVerified: true, - verificationTaskId: 'task-verify-1', + verificationTaskId: 'task-verify-1' as string | null, verificationTaskActive: true, verifiedAt: new Date('2026-03-25T09:00:00.000Z'), verificationError: null, @@ -462,6 +462,22 @@ describe('Environments', () => { 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/system/primitives/badge.tsx b/apps/web/src/components/system/primitives/badge.tsx index eed0c577..a383b725 100644 --- a/apps/web/src/components/system/primitives/badge.tsx +++ b/apps/web/src/components/system/primitives/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const badgeVariants = cva( - 'inline-flex items-center cursor-default justify-center rounded-lg px-2 py-0.75 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + 'inline-flex items-center cursor-default justify-center rounded-lg border px-2 py-0.75 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', { variants: { variant: { diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index a3389317..76dea06a 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -14,6 +14,7 @@ import { db, environments, eq, + inArray, isNotNull, not, resolveEffectivePreviewRuntimeConfig, @@ -55,7 +56,6 @@ import { const SANDBOX_PROMPT_TOKEN_TIMEOUT_MS = 15 * 60 * 1000; const SANDBOX_PROMPT_TIMEOUT_MS = 30_000; const SANDBOX_RPC_HEALTHCHECK_TIMEOUT_MS = 5_000; -const activeVerificationTaskStatuses: readonly RunStatus[] = activeRunStatuses; const requestUserInputAnswersSchema = z.record( z.object({ answers: z.array(z.string()), @@ -617,7 +617,10 @@ export async function getSandboxSessionByTaskIdCommand( const activeVerificationTaskRun = onboardingEnvironment?.verificationTaskId ? await db.query.taskRuns.findFirst({ - where: eq(taskRuns.taskId, onboardingEnvironment.verificationTaskId), + where: and( + eq(taskRuns.taskId, onboardingEnvironment.verificationTaskId), + inArray(taskRuns.status, [...activeRunStatuses]), + ), columns: { status: true }, }) : null; @@ -625,12 +628,7 @@ export async function getSandboxSessionByTaskIdCommand( const onboardingEnvironmentWithVerification = onboardingEnvironment ? { ...onboardingEnvironment, - verificationTaskActive: - activeVerificationTaskRun !== null && - activeVerificationTaskRun !== undefined && - activeVerificationTaskStatuses.includes( - activeVerificationTaskRun.status, - ), + verificationTaskActive: activeVerificationTaskRun !== null, } : null; From 5f666c9ab0129214d7238bd4f9c3a1313bbfb12e Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 15 Jul 2026 20:34:02 +0100 Subject: [PATCH 21/21] fix: complete verification feedback follow-ups --- .../[taskId]/ErrorFallback.client.test.tsx | 62 +++++++++++++++++-- .../(sandbox)/task/[taskId]/ErrorFallback.tsx | 14 ++++- .../trpc/commands/sandbox-session/index.ts | 2 +- 3 files changed, 71 insertions(+), 7 deletions(-) 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 ee7cdd2b..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 @@ -203,15 +203,17 @@ describe('ConnectionStatusBanner', () => { ).not.toBeInTheDocument(); }); - it('shows a retry action when initial retries are exhausted', () => { - const refreshConnection = vi.fn(); + 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( @@ -231,11 +233,47 @@ describe('ConnectionStatusBanner', () => { ).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, @@ -451,8 +489,19 @@ describe('ConnectionStatusBanner', () => { ).toBeInTheDocument(); }); - it('shows a retry action for token/bootstrap failures', () => { - const refreshConnection = vi.fn(); + 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( { ).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 3e0ba578..61462b62 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/ErrorFallback.tsx @@ -47,6 +47,7 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { hasConnectedOnce, connectionError, connectionFailureCategory, + reconnect, reconnecting, } = useSandboxConnectionStatus(); @@ -107,7 +108,18 @@ export function ConnectionStatusBanner({ session }: { session: TaskSession }) { variant="outline" size="sm" onClick={() => { - void session.refreshConnection(); + void session + .refreshConnection() + .then((connectionTarget) => { + reconnect(connectionTarget); + }) + .catch((error) => { + console.warn( + '[ConnectionStatusBanner] failed to refresh sandbox connection', + error, + ); + reconnect(null); + }); }} > diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index 76dea06a..3cf1a498 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -628,7 +628,7 @@ export async function getSandboxSessionByTaskIdCommand( const onboardingEnvironmentWithVerification = onboardingEnvironment ? { ...onboardingEnvironment, - verificationTaskActive: activeVerificationTaskRun !== null, + verificationTaskActive: Boolean(activeVerificationTaskRun), } : null;