diff --git a/apps/bullmq/package.json b/apps/bullmq/package.json index 3858ea93b..abbc24cdf 100644 --- a/apps/bullmq/package.json +++ b/apps/bullmq/package.json @@ -27,6 +27,7 @@ "@roomote/db": "workspace:^", "@roomote/discord-gateway": "workspace:^", "@roomote/env": "workspace:^", + "@roomote/feature-flags": "workspace:^", "@roomote/github": "workspace:^", "@roomote/linear": "workspace:^", "@roomote/sdk": "workspace:^", diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts new file mode 100644 index 000000000..ccdc8aab2 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -0,0 +1,57 @@ +import { + db, + deploymentSettings, + eq, + fastAgentConversations, + sessionTasks, + sessions, + taskFactory, + userFactory, +} from '@roomote/db/server'; +import { sessionsReconcileJob } from '../sessions-reconcile'; + +describe('sessionsReconcileJob', () => { + beforeEach(async () => { + await db + .insert(deploymentSettings) + .values({ id: 'default', metadata: { sessions_data: true } }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { metadata: { sessions_data: true } }, + }); + }); + + afterEach(async () => { + await db + .update(deploymentSettings) + .set({ metadata: {} }) + .where(eq(deploymentSettings.id, 'default')); + }); + + it('backfills Fast conversations and visible tasks idempotently', async () => { + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/index.ts b/apps/bullmq/src/scheduled-jobs/index.ts index 6be114ec8..a2b2061f0 100644 --- a/apps/bullmq/src/scheduled-jobs/index.ts +++ b/apps/bullmq/src/scheduled-jobs/index.ts @@ -9,3 +9,4 @@ export { standbyRetentionJob } from './standby-retention'; export { prReviewNotificationDispatchJob } from './pr-review-notification-dispatch'; export { brainOutboxDrainJob, brainCollectorsJob } from './brain-outbox-drain'; export { brainMaintenanceJob } from './brain-maintenance'; +export { sessionsReconcileJob } from './sessions-reconcile'; diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts new file mode 100644 index 000000000..470217c30 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -0,0 +1,245 @@ +import { + and, + db, + desc, + ensureSessionForFastConversation, + ensureSessionForTask, + eq, + fastAgentConversations, + gt, + isNull, + or, + sessionBackfillState, + sessions, + sessionTasks, + sql, + taskRuns, + tasks, + touchSessionActivity, +} from '@roomote/db/server'; +import { + evaluateDeploymentFeatureFlag, + FeatureFlag, +} from '@roomote/feature-flags/server'; + +const LOG_PREFIX = '[sessions]'; +const BACKFILL_KEY = 'unified-sessions-v1'; +const BATCH_SIZE = 100; + +type Cursor = { createdAt: Date; id: string } | null; + +function afterCursor( + createdAt: TCreatedAt, + id: TId, + cursor: Cursor, +) { + return cursor + ? or( + gt(createdAt as never, cursor.createdAt), + and( + eq(createdAt as never, cursor.createdAt), + gt(id as never, cursor.id), + ), + ) + : undefined; +} + +async function updateState(input: { + phase: 'fast_conversations' | 'tasks' | 'participants'; + cursor?: Cursor; + completed?: boolean; +}) { + await db + .insert(sessionBackfillState) + .values({ + key: BACKFILL_KEY, + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function backfillFastConversations(cursor: Cursor): Promise { + const rows = await db + .select({ + id: fastAgentConversations.id, + createdAt: fastAgentConversations.createdAt, + }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + afterCursor( + fastAgentConversations.createdAt, + fastAgentConversations.id, + cursor, + ), + ), + ) + .orderBy(fastAgentConversations.createdAt, fastAgentConversations.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + await db.transaction((tx) => ensureSessionForFastConversation(tx, row.id)); + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'fast_conversations' : 'tasks', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill fast conversations`, { + processed: rows.length, + }); + return rows.length < BATCH_SIZE; +} + +async function backfillTasks(cursor: Cursor): Promise { + const rows = await db + .select({ id: tasks.id, createdAt: tasks.createdAt }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + afterCursor(tasks.createdAt, tasks.id, cursor), + ), + ) + .orderBy(tasks.createdAt, tasks.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + const latestFastRun = await db.query.taskRuns.findFirst({ + where: and( + eq(taskRuns.taskId, row.id), + sql`${taskRuns.fastAgentSessionId} IS NOT NULL`, + ), + columns: { fastAgentSessionId: true }, + orderBy: desc(taskRuns.id), + }); + await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: row.id, + fastConversationId: latestFastRun?.fastAgentSessionId ?? null, + origin: 'backfill', + }), + ); + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'tasks' : 'participants', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill tasks`, { processed: rows.length }); + return rows.length < BATCH_SIZE; +} + +async function backfillParticipants(): Promise { + await db.execute(sql` + INSERT INTO session_participants (session_id, user_id, role) + SELECT DISTINCT s.id, fam.metadata->>'userId', 'member' + FROM sessions s + JOIN fast_agent_messages fam ON fam.conversation_id = s.fast_conversation_id + JOIN users u ON u.id = fam.metadata->>'userId' AND u.deleted_at IS NULL + WHERE fam.metadata->>'userId' IS NOT NULL + ON CONFLICT (session_id, user_id) DO NOTHING + `); + await updateState({ phase: 'participants', completed: true }); + console.info(`${LOG_PREFIX} backfill participants complete`); +} + +async function reconcileRecentSessions(): Promise { + const orphanTasks = await db + .select({ id: tasks.id }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + ), + ) + .orderBy(desc(tasks.activityAt)) + .limit(BATCH_SIZE); + + for (const task of orphanTasks) { + await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), + ); + } + + const recent = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where(eq(sessions.visibility, 'visible')) + .orderBy(desc(sessions.activityAt)) + .limit(BATCH_SIZE); + for (const session of recent) { + await touchSessionActivity(db, session.id, session.activityAt); + } + + console.info(`${LOG_PREFIX} reconciliation`, { + orphanVisibleTasks: orphanTasks.length, + refreshedSessions: recent.length, + }); +} + +export async function sessionsReconcileJob(): Promise { + const enabled = await evaluateDeploymentFeatureFlag(FeatureFlag.SessionsData); + if (!enabled) return; + + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + if (state?.completedAt) { + await reconcileRecentSessions(); + return; + } + + const phase = state?.phase ?? 'fast_conversations'; + const cursor = + state?.cursorCreatedAt && state.cursorId + ? { createdAt: state.cursorCreatedAt, id: state.cursorId } + : null; + + if (phase === 'fast_conversations') { + const complete = await backfillFastConversations(cursor); + if (!complete) return; + } + if ( + phase === 'fast_conversations' || + phase === 'fast_tasks' || + phase === 'tasks' + ) { + const complete = await backfillTasks(phase === 'tasks' ? cursor : null); + if (!complete) return; + } + await backfillParticipants(); +} diff --git a/apps/bullmq/src/scheduler.ts b/apps/bullmq/src/scheduler.ts index 360d37127..44b34dfa5 100644 --- a/apps/bullmq/src/scheduler.ts +++ b/apps/bullmq/src/scheduler.ts @@ -35,6 +35,7 @@ import { brainOutboxDrainJob, brainCollectorsJob, brainMaintenanceJob, + sessionsReconcileJob, } from './scheduled-jobs'; const QUEUE_NAME = 'scheduled-jobs'; @@ -225,6 +226,10 @@ async function createJobs(queue: Queue): Promise { { pattern: '0 7 * * *' }, ); + await queue.upsertJobScheduler(ScheduledJobName.SessionsReconcile, { + every: 60 * 1000, + }); + const schedulers = await queue.getJobSchedulers(); console.log('[createJobs] getJobSchedulers ->', schedulers); } @@ -266,6 +271,8 @@ const runJobs = async (job: ScheduledJob): Promise => { return brainCollectorsJob(); case ScheduledJobName.BrainMaintenance: return brainMaintenanceJob(); + case ScheduledJobName.SessionsReconcile: + return sessionsReconcileJob(); case ScheduledJobName.CustomAutomations: await customAutomationsJob(); return; diff --git a/apps/bullmq/src/types.ts b/apps/bullmq/src/types.ts index 6393c98a6..9e3730035 100644 --- a/apps/bullmq/src/types.ts +++ b/apps/bullmq/src/types.ts @@ -18,6 +18,7 @@ export enum ScheduledJobName { BrainOutboxDrain = 'BrainOutboxDrain', BrainCollectors = 'BrainCollectors', BrainMaintenance = 'BrainMaintenance', + SessionsReconcile = 'SessionsReconcile', } /** diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 9fbfd2b5e..ee44179eb 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -1,32 +1,41 @@ --- -title: Fast sessions -icon: zap -description: Chat with the fast orchestrator from the dashboard and review every Fast session transcript. +title: Sessions +icon: messages-square +description: Follow a conversation and every execution it delegates from one continuous Roomote workspace. --- -Fast is Roomote's conversational orchestrator: it answers directly when it can -and delegates execution work into tasks when needed. A Fast session persists -across Slack, Discord, Microsoft Teams, Telegram, an automation, or the web -dashboard. +Sessions are the primary way to follow work in Roomote. A Session keeps the +conversation, delegated executions, review activity, artifacts, pull requests, +cost, and unread state together, whether it started in chat, source control, an +automation, the API, or the web dashboard. Fast is the conversational +orchestrator inside a Session: it answers directly when it can and delegates +execution work when needed across Slack, Discord, Microsoft Teams, Telegram, +automations, and the web dashboard. -## Start a Fast session from the dashboard +## Start a Session from the dashboard -On the home page, open the workspace selector next to the prompt box and choose -**Fast**. Your prompt starts a Fast session instead of a sandbox task, and -Roomote takes you straight to the session view, where the response streams in -as it is produced. +On the home page, leave the workspace selector on **Auto** to start a +conversation. Roomote answers directly when it can and delegates execution +when the request needs a repository workspace. Selecting an environment or +repository starts the execution directly, but Roomote still creates the +owning Session and opens it with that execution selected. -Use Fast when you want an answer, a decision, or a delegation rather than a -full sandbox run. Fast can still launch tasks on your behalf; delegated tasks -appear in the transcript with links to their task pages. +You do not need to choose a separate conversation mode. The Session grows from +conversation to execution to review without changing identity. ## The session view -A session's transcript shows prompts, replies, and the tool activity behind -them, rendered with the same transcript view as tasks, with a generated title -that updates as the session evolves. The view updates in real time while -a turn is running, so you can watch tool calls complete and replies land -without refreshing. +A Session timeline shows prompts, replies, and delegated execution activity. +Execution cards show their status, workspace, pull requests, artifacts, latest +error, and cost. Select a card to open the lightweight details panel, or choose +**Open full workspace** for terminal, logs, diff, and preview tools. + +The Sessions page supports list and board views, filters, search, pins, recent +Sessions, and unread indicators. **Ready** is not a terminal state: you can +reply or start another execution in the same Session later. + +The transcript renders prompts, replies, and tool activity in real time with a +generated title that updates as the Session evolves. Fast sessions can also render presentational widgets such as status cards, tables, and plans directly in the transcript. Widget HTML is sanitized and @@ -35,15 +44,21 @@ fallback. ## Reply to a session -Every session has a reply box at the bottom of the transcript; follow-ups -continue the same session with full context. For sessions that live -on another surface, such as a Slack thread, Roomote's answer is posted back -into the originating thread with a quoted copy of your web message, so the -session stays in one place for everyone following it there. Fast replies -across Slack, Discord, Microsoft Teams, and Telegram carry a "Reply or use the -web app" footer linking to the session view. Slack and Discord can also resume -Fast directly from chat. Microsoft Teams replies to Fast session and automation -messages also continue the same session after Roomote verifies the tenant, -installation, conversation, and linked user. Telegram currently uses the -session view for Fast follow-ups because its inbound webhook route does not yet -carry Fast session identity. +Conversational Sessions have a reply box at the bottom of the transcript; +follow-ups continue the same conversation with full context. For conversations +that live on another surface, Roomote posts the answer back into the originating +thread with a quoted copy of your web message, so the conversation stays in one +place for everyone following it there. + +Fast replies across Slack, Discord, Microsoft Teams, and Telegram link back to +the Session view. Slack and Discord can also resume Fast directly from chat. +Microsoft Teams replies continue the same Session after Roomote verifies the +tenant, installation, conversation, and linked user. Telegram currently uses +the Session view for Fast follow-ups because its inbound webhook route does not +yet carry Fast Session identity. + +## Execution access + +Session participants can see timeline summaries. Full execution details keep +the existing task permissions, so joining a shared channel does not grant +access to logs, terminals, diffs, previews, or private artifacts. diff --git a/apps/docs/tasks.mdx b/apps/docs/tasks.mdx index ce1107bea..760b77cda 100644 --- a/apps/docs/tasks.mdx +++ b/apps/docs/tasks.mdx @@ -4,9 +4,10 @@ icon: clipboard-check description: Inspect the transcript, logs, diffs, previews, and follow-up path before you trust the result. --- -A task is a single unit of Roomote work. It may start from chat, source -control, Linear, or the web dashboard, but the task view gives your team one -shared place to inspect what happened and decide what should happen next. +A task is one independently controllable execution inside a Session. It may +start from chat, source control, Linear, the API, or the web dashboard. The +task workspace remains the place to inspect operational details such as logs, +terminal output, diffs, previews, retries, and artifacts. Use the task view as the handoff point between Roomote and your normal review process. A task is complete only when the evidence is clear enough for a @@ -24,19 +25,17 @@ Before you dive into details, check the basics: - whether the end state matches the kind of outcome you wanted: answer, plan, patch, branch, or PR -## Task board +## Sessions and the task board -Use the board view on the Tasks page to scan shared work by lifecycle. Roomote -places tasks in **Active**, **Needs input**, **Blocked / failed**, or **Done** +Use the board view on the Sessions page to scan shared work by lifecycle. +Roomote places Sessions in **Active**, **Needs input**, **Blocked**, or **Ready** from their current task, goal, and run state, so your team does not need to maintain a separate status field. -Each card shows who started the task, participant avatars, recent activity, and -available workspace or pull-request context. The Done column keeps the six most -recent completed tasks so finished work does not overwhelm active work. Board -and list choices remain in the URL so views are shareable. Roomote also restores -the most recently selected layout from browser storage when you return; if -browser storage is unavailable, the Tasks page falls back to list view. +Each Session card shows its owner and participants, recent activity, delegated +execution count, workspace or pull-request context, aggregate cost, and unread +state. Use the **Tasks** scope when you only want Sessions containing execution +work. Board and list choices remain in the URL so views are shareable. ## Recover from a failed start @@ -50,6 +49,9 @@ reattach any files the new task needs. The task view gives you the working context for a run: +The header breadcrumb links back to the owning Session. When you opened the +workspace from a filtered Sessions view, browser Back returns to that view. + - conversation history and Roomote updates - inline widgets for structured tables, status cards, plans, and other presentational results an agent chooses to show diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx index 47013ad37..f3451106b 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx @@ -56,6 +56,8 @@ const analyticsFilterKeys = [ 'taskType', 'provider', 'model', + 'ownerKind', + 'hasExecution', ] as const; type SelectedAnalyticsSegment = { @@ -65,7 +67,11 @@ type SelectedAnalyticsSegment = { seriesLabel: string; }; -const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = ['tasks', 'pullRequests']; +const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = [ + 'tasks', + 'sessions', + 'pullRequests', +]; function parseAnalyticsObject( value: string | null, @@ -75,7 +81,7 @@ function parseAnalyticsObject( return value as AnalyticsObject; } - return allowedObjects[0] ?? analyticsObjects[0]; + return allowedObjects[0] ?? analyticsObjects[0] ?? 'tasks'; } function getFiltersFromSearchParams( diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx index 7f0acd1fd..c0513d34c 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx @@ -42,12 +42,14 @@ type AnalyticsDetailsDialogProps = { }; const DIALOG_WIDTH_BY_OBJECT: Record = { + sessions: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', tasks: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', pullRequests: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', costs: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', }; const TABLE_MIN_WIDTH_BY_OBJECT: Record = { + sessions: 'min-w-[900px] md:min-w-[1040px]', tasks: 'min-w-[980px] md:min-w-[1100px]', pullRequests: 'min-w-[1140px] md:min-w-[1220px]', costs: 'min-w-[1140px] md:min-w-[1220px]', diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts index 0e440a02d..35a5b9c57 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts @@ -10,6 +10,7 @@ import { GitPullRequest, RadioTower, VectorSquare, + Rows4, } from '@/components/system'; export const ANALYTICS_DIMENSION_ICONS: Record< @@ -25,4 +26,6 @@ export const ANALYTICS_DIMENSION_ICONS: Record< taskType: Bot, provider: Cpu, model: Brain, + ownerKind: Bot, + hasExecution: Rows4, }; diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx index c2dcda2d3..a7b2e2c7f 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx @@ -39,6 +39,8 @@ const ANALYTICS_DIMENSION_PLURAL_LABELS: Record = { taskType: 'Task Types', provider: 'Providers', model: 'Models', + ownerKind: 'Owner kinds', + hasExecution: 'Execution states', }; type AnalyticsFilterBarProps = { diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx index 2311c1ac7..f5caa6d23 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx @@ -16,6 +16,8 @@ type AnalyticsShellItemId = AnalyticsObject; export function getAnalyticsHref(itemId: AnalyticsShellItemId) { switch (itemId) { + case 'sessions': + return '/analytics?object=sessions'; case 'tasks': return '/analytics'; case 'pullRequests': @@ -26,6 +28,7 @@ export function getAnalyticsHref(itemId: AnalyticsShellItemId) { } const ANALYTICS_SHELL_ITEMS = [ + { id: 'sessions', label: 'Sessions', icon: ChartColumnIncreasing }, { id: 'tasks', label: 'Tasks', icon: ChartColumnIncreasing }, { id: 'costs', label: 'Costs', icon: CircleDollarSign }, ] as const satisfies Array<{ @@ -35,6 +38,7 @@ const ANALYTICS_SHELL_ITEMS = [ }>; const ANALYTICS_DESCRIPTIONS: Record = { + sessions: 'Track Session activity by owner, status, and source.', pullRequests: 'Track pull request activity by user, status, repository, and author.', tasks: 'Track task activity by user, environment, source, and task type.', diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index a49ad66e2..0b5a17fbd 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -21,6 +21,7 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ let currentEnvironmentsPending = false; let currentCommunicationsFastModeDefault = false; let currentPersonalPreferencesLoading = false; +let currentSessionsUiEnabled = false; const { mockPush, @@ -74,6 +75,7 @@ vi.mock('@/hooks/useUser', () => ({ name: 'Test User', primaryEmail: 'test@example.com', cloudEnabled: currentCloudEnabled, + featureFlags: { sessions_ui: currentSessionsUiEnabled }, resource: { username: 'tester', fullName: 'Test User', @@ -398,6 +400,7 @@ describe('Home', () => { currentEnvironmentsPending = false; currentCommunicationsFastModeDefault = false; currentPersonalPreferencesLoading = false; + currentSessionsUiEnabled = false; localStorage.clear(); vi.clearAllMocks(); @@ -1139,6 +1142,27 @@ describe('Home', () => { ).toBeDisabled(); }); + it('starts an Auto Session without an environment when Sessions UI is enabled', async () => { + currentEnvironments = []; + currentSessionsUiEnabled = true; + + render(); + + const submitButton = screen.getByRole('button', { name: 'Submit prompt' }); + expect(submitButton).toBeEnabled(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockStartFastSession).toHaveBeenCalledWith({ + text: 'Test prompt', + images: undefined, + model: 'openrouter/openai/gpt-5.4', + }); + }); + expect(mockRouteHomeTask).not.toHaveBeenCalled(); + expect(mockCreateStandardTaskRun).not.toHaveBeenCalled(); + }); + it('does not show the empty-environments warning while environments are loading', () => { currentEnvironments = undefined; currentEnvironmentsPending = true; diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 9d007064c..f18cadd36 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -149,8 +149,10 @@ export function Home({ const { cloudEnabled, isAdmin, + featureFlags, managedAccess = DEFAULT_MANAGED_DEPLOYMENT_ACCESS, } = useAuthorizedUser(); + const sessionsUiEnabled = featureFlags?.sessions_ui === true; const canSelectBranch = false; @@ -442,11 +444,16 @@ export function Home({ const navigateToTaskRun = (result: { success: boolean; taskId?: string; + sessionId?: string; error?: string; }) => { if (result.success && 'taskId' in result) { setIsExiting(true); - router.push(`/task/${result.taskId}`); + router.push( + sessionsUiEnabled && result.sessionId + ? `/sessions/${result.sessionId}?task=${result.taskId}` + : `/task/${result.taskId}`, + ); } else if ('error' in result) { toast.error(result.error); } @@ -562,9 +569,13 @@ export function Home({ const hasAnyEnvironments = (environments.data?.length ?? 0) > 0; const showNoEnvironmentsWarning = isAdmin && !environments.isPending && !hasAnyEnvironments; + const autoRoutingNeedsEnvironment = + !sessionsUiEnabled && + !hasAnyEnvironments && + watchedRepository === AUTO_WORKSPACE_VALUE; const submitDisabledReason = getTaskLaunchDisabledReason(managedAccess) ?? - (!hasAnyEnvironments && watchedRepository === AUTO_WORKSPACE_VALUE + (autoRoutingNeedsEnvironment ? 'Auto routing needs an environment. Create one, or select All Repositories to work without one.' : undefined); @@ -691,6 +702,16 @@ export function Home({ return; } + if (isAutoWorkspace && sessionsUiEnabled) { + if (!submission.description && !submission.images?.length) return; + await startFastSession({ + text: submission.description ?? '', + images: submission.images, + model: selectedModelId, + }); + return; + } + if (isAutoWorkspace) { await handleAutoSubmit(submission); return; @@ -724,6 +745,7 @@ export function Home({ wiggleWorkspace, startFastSession, selectedModelId, + sessionsUiEnabled, ], ); @@ -758,7 +780,7 @@ export function Home({
; +}; + +const STATUS_VARIANTS = { + active: 'success', + needs_input: 'warning', + blocked: 'destructive', + ready: 'secondary', +} as const; + +export function SessionCard({ session }: { session: SessionCardData }) { + const owner = + getUserDisplayName({ + name: session.ownerName, + email: session.ownerEmail, + }) ?? 'Roomote'; + const primaryTask = session.tasks[0]; + const status = session.cachedStatus ?? 'ready'; + + return ( + +
+ + {session.unread ? ( + + ) : null} +
+
+
+

+ {session.title} +

+ + {formatDistanceToNow(new Date(session.activityAt * 1000), { + addSuffix: true, + })} + +
+
+ + {status.replace('_', ' ')} + + {session.executionCount} executions + {session.sourceSurface} + {primaryTask?.repositoryName ? ( + {primaryTask.repositoryName} + ) : null} + ${(session.inferenceCostMicroUsd / 1_000_000).toFixed(4)} +
+
+ + ); +} diff --git a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx index 51b8c9fb2..60452c892 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx @@ -5,13 +5,42 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { TimePeriodFilter } from '@/types'; import { TaskFilters } from '@/components/tasks'; +import { + Button, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/system'; export function SessionsFilters({ userId, timePeriod, + unified = false, + scope = 'all', + status = 'all', + view = 'list', + query = '', + repository = null, + pullRequest = null, + model = null, + source = 'all', + environment = '', }: { userId: string | null; timePeriod: TimePeriodFilter; + unified?: boolean; + scope?: string; + status?: string; + view?: string; + query?: string; + repository?: string | null; + pullRequest?: string | null; + model?: string | null; + source?: string; + environment?: string; }) { const router = useRouter(); const pathname = usePathname(); @@ -30,37 +59,166 @@ export function SessionsFilters({ ); return ( - - updateParams((params) => { - if (id && id !== 'all') { - params.set('user', id); - } else { - params.delete('user'); - } - }) - } - onRepositoryChange={() => {}} - onPullRequestChange={() => {}} - onModelChange={() => {}} - onTimePeriodChange={(period) => - updateParams((params) => { - if (period === 'all') { - params.delete('period'); - } else { - params.set('period', String(period)); - } - }) - } - showRepository={false} - showPullRequest={false} - showModel={false} - showTaskType={false} - /> +
+ {unified ? ( + <> + + + +
{ + event.preventDefault(); + const form = new FormData(event.currentTarget); + updateParams((params) => { + const value = String(form.get('q') ?? '').trim(); + if (value) params.set('q', value); + else params.delete('q'); + const environmentValue = String( + form.get('environment') ?? '', + ).trim(); + if (environmentValue) + params.set('environment', environmentValue); + else params.delete('environment'); + }); + }} + > + + + +
+ + + ) : null} + + updateParams((params) => { + if (id && id !== 'all') { + params.set('user', id); + } else { + params.delete('user'); + } + }) + } + onRepositoryChange={(value) => + updateParams((params) => { + if (value) params.set('repository', value); + else params.delete('repository'); + }) + } + onPullRequestChange={(value) => + updateParams((params) => { + if (value) params.set('pullRequest', value); + else params.delete('pullRequest'); + }) + } + onModelChange={(value) => + updateParams((params) => { + if (value) params.set('model', value); + else params.delete('model'); + }) + } + onTimePeriodChange={(period) => + updateParams((params) => { + if (period === 'all') { + params.delete('period'); + } else { + params.set('period', String(period)); + } + }) + } + showRepository={unified} + showPullRequest={unified} + showModel={unified} + showTaskType={false} + /> +
); } diff --git a/apps/web/src/app/(authenticated)/sessions/page.tsx b/apps/web/src/app/(authenticated)/sessions/page.tsx index 8a794905c..e7abfa15b 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.tsx @@ -4,25 +4,145 @@ import { notFound } from 'next/navigation'; import { parseTimePeriodParam } from '@/types'; import { authorize } from '@/lib/server/auth-context'; import { getFastSessions } from '@/lib/server/fast-sessions'; +import { getSessions, type SessionScope } from '@/lib/server/sessions'; import { Empty, EmptyDescription, EmptyHeader } from '@/components/system'; import { FastSessionCard } from './FastSessionCard'; import { SessionsFilters } from './SessionsFilters'; +import { SessionCard } from './SessionCard'; export default async function SessionsPage({ searchParams, }: { - searchParams?: Promise<{ before?: string; user?: string; period?: string }>; + searchParams?: Promise<{ + before?: string; + user?: string; + period?: string; + scope?: string; + status?: string; + view?: string; + q?: string; + repository?: string; + environment?: string; + pullRequest?: string; + source?: string; + model?: string; + }>; }) { - const [authorizedUser, { before, user, period } = {}] = await Promise.all([ + const [authorizedUser, params = {}] = await Promise.all([ authorize(), searchParams, ]); if (!authorizedUser.success) { notFound(); } + const { before, user, period, q } = params; + const unified = authorizedUser.featureFlags.sessions_ui === true; + const scope = ['all', 'tasks', 'reviews', 'automations'].includes( + params.scope ?? '', + ) + ? (params.scope as SessionScope) + : 'all'; + const status = ['active', 'needs_input', 'blocked', 'ready'].includes( + params.status ?? '', + ) + ? (params.status as 'active' | 'needs_input' | 'blocked' | 'ready') + : undefined; + const view = params.view === 'board' ? 'board' : 'list'; const timePeriod = parseTimePeriodParam(period ?? null, 'all'); + if (unified) { + const result = await getSessions(authorizedUser, { + before, + user, + period: timePeriod, + scope, + status, + q, + repository: params.repository, + environment: params.environment, + pullRequest: params.pullRequest, + source: params.source, + model: params.model, + }); + const olderParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value && key !== 'before') olderParams.set(key, value); + }); + if (result.nextCursor) olderParams.set('before', result.nextCursor); + const columns = ['active', 'needs_input', 'blocked', 'ready'] as const; + + return ( +
+
+ +
+
+ {result.sessions.length === 0 ? ( + + + No sessions found. + + + ) : view === 'board' ? ( +
+ {columns.map((column) => ( +
+

+ {column.replace('_', ' ')} +

+
+ {result.sessions + .filter((session) => + column === 'ready' + ? !session.cachedStatus || + session.cachedStatus === column + : session.cachedStatus === column, + ) + .map((session) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ {result.sessions.map((session) => ( + + ))} +
+ )} + {result.nextCursor ? ( +
+ + Show older sessions + +
+ ) : null} +
+
+ ); + } const { sessions, nextCursor } = await getFastSessions(authorizedUser, { before, filterUserId: user ?? null, diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx index 32b5c5f48..72895ac17 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -1,13 +1,16 @@ 'use client'; import { useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { Tasks } from './Tasks'; +import { useAuthorizedUser } from '@/hooks/useUser'; export default function Page() { const searchParams = useSearchParams(); + const router = useRouter(); + const { featureFlags } = useAuthorizedUser(); const error = searchParams.get('error'); useEffect(() => { @@ -16,5 +19,26 @@ export default function Page() { } }, [error]); + useEffect(() => { + if (featureFlags?.sessions_ui !== true) return; + const mapped = new URLSearchParams(); + mapped.set('scope', 'tasks'); + const mappings = [ + ['userId', 'user'], + ['timePeriod', 'period'], + ['repositoryName', 'repository'], + ['pullRequest', 'pullRequest'], + ['model', 'model'], + ['view', 'view'], + ] as const; + for (const [from, to] of mappings) { + const value = searchParams.get(from); + if (value) mapped.set(to, value); + } + router.replace(`/sessions?${mapped.toString()}`); + }, [featureFlags?.sessions_ui, router, searchParams]); + + if (featureFlags?.sessions_ui === true) return null; + return ; } diff --git a/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx new file mode 100644 index 000000000..967f94001 --- /dev/null +++ b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +import { SandboxSidePanelHeader } from './SandboxSidePanelHeader'; + +export function SandboxInfoPanel({ + title, + onClose, + closeLabel, + header, + children, +}: { + title: string; + onClose: () => void; + closeLabel?: string; + header?: ReactNode; + children: ReactNode; +}) { + return ( + <> + {header ?? ( + + )} +
+
{children}
+
+ + ); +} + +export function SandboxInfoRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {label} + {children} + + ); +} + +export function SandboxInfoTable({ children }: { children: ReactNode }) { + return ( + + {children} +
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 5d8930f6d..31d874a2f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -9,10 +9,13 @@ import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; import { FastSessionTranscript } from './FastSessionTranscript'; -const { replyMutate, preparePromptAttachments } = vi.hoisted(() => ({ - replyMutate: vi.fn(), - preparePromptAttachments: vi.fn(), -})); +const { replyMutate, preparePromptAttachments, openTaskPanel } = vi.hoisted( + () => ({ + replyMutate: vi.fn(), + preparePromptAttachments: vi.fn(), + openTaskPanel: vi.fn(), + }), +); vi.mock('@/trpc/client', () => ({ useTRPCClient: () => ({ @@ -38,6 +41,24 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('./session-task-panel-context', () => ({ + useOpenSessionTaskPanel: () => openTaskPanel, +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + onOpen, + }: { + taskId: string; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + class FakeEventSource { static instances: FakeEventSource[] = []; listeners = new Map void>>(); @@ -71,6 +92,7 @@ beforeEach(() => { preparePromptAttachments.mockImplementation(({ text }: { text: string }) => Promise.resolve({ text }), ); + openTaskPanel.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); @@ -279,6 +301,68 @@ describe('FastSessionTranscript', () => { ); }); + it('opens a launched child task in the session side panel', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Delegated task/ })); + + expect(openTaskPanel).toHaveBeenCalledWith('child-1'); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( { , ); - expect(screen.getByText('Session')).toBeInTheDocument(); + expect(screen.getByText('New session')).toBeInTheDocument(); act(() => { FakeEventSource.instances[0]!.emit('session', { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8066694fc..dffe01ec1 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -1,6 +1,13 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { ACP_ENVELOPE_EVENT_TYPES, getImageUrisFromContentBlocks, @@ -24,6 +31,7 @@ import { type SessionPromptSubmission, } from './SessionPromptInput'; import { preparePromptAttachments } from '@/lib/prompt-attachments'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; import { AcpTranscriptBlockList, @@ -56,11 +64,13 @@ export function FastSessionTranscript({ hasOlderMessages, canReply, initialTitle = null, - fallbackTitle = 'Session', + fallbackTitle = 'New session', sessionModel = null, sessionReasoningEffort = null, defaultModelId = null, defaultReasoningEffort = null, + headerExtras, + timelineExtras, }: { sessionId: string; initialMessages: FastSessionMessage[]; @@ -72,8 +82,11 @@ export function FastSessionTranscript({ sessionReasoningEffort?: ReasoningEffort | null; defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; + headerExtras?: ReactNode; + timelineExtras?: ReactNode; }) { const trpcClient = useTRPCClient(); + const openTaskPanel = useOpenSessionTaskPanel(); const [serverMessages, setServerMessages] = useState< Map >( @@ -174,6 +187,7 @@ export function FastSessionTranscript({ shouldHideFirstMessage: false, showInternalMessages: false, hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); @@ -255,10 +269,14 @@ export function FastSessionTranscript({ return ( - +

{title ?? fallbackTitle}

+ {headerExtras}
@@ -267,10 +285,12 @@ export function FastSessionTranscript({ Older messages in this session are not shown.

) : null} + {timelineExtras}
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx new file mode 100644 index 000000000..6e663264b --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { RunStatus } from '@roomote/types'; + +const useTaskSessionMock = vi.fn(); + +vi.mock('../../task/[taskId]/hooks/use-task-session', () => ({ + useTaskSession: (...args: unknown[]) => useTaskSessionMock(...args), +})); + +vi.mock('../../task/[taskId]/hooks/use-task-message-envelopes', () => ({ + useTaskMessageEnvelopes: () => ({ + data: [], + isPending: false, + isSuccess: true, + isError: false, + }), +})); + +vi.mock('../../task/[taskId]/hooks/ArtifactLinkProvider', () => ({ + ArtifactLinkProvider: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('../../task/[taskId]/hooks/HistoricalSandboxProvider', () => ({ + HistoricalSandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/hooks/SandboxProvider', () => ({ + SandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/Messages', () => ({ + Messages: () =>
Child transcript
, +})); + +vi.mock('../../task/[taskId]/sidebar-panels/SidePanelHeader', () => ({ + SidePanelHeader: ({ + title, + actions, + }: { + title: string; + actions: ReactNode; + }) => ( +
+ {title} + {actions} +
+ ), +})); + +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; + +describe('NestedTaskSidePanel', () => { + beforeEach(() => { + useTaskSessionMock.mockReturnValue({ + taskId: 'child-1', + task: { title: 'Fix checkout' }, + taskRun: { + id: 42, + harness: 'opencode-server', + status: RunStatus.Running, + taskPhase: 'running', + sandboxServerUrl: 'http://sandbox.test', + }, + artifacts: [], + prompt: null, + token: 'token', + refreshConnection: vi.fn(), + sessionState: 'interactive', + isSessionLoading: false, + }); + }); + + it('renders the focused live transcript and full-task navigation without task chrome', () => { + render(); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByTestId('live-provider')).toBeInTheDocument(); + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Go to task/ })).toHaveAttribute( + 'href', + '/task/child-1', + ); + expect(screen.queryByText('Task actions')).not.toBeInTheDocument(); + expect(useTaskSessionMock).toHaveBeenCalledWith('child-1', { + refetchInterval: 2_000, + }); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx new file mode 100644 index 000000000..5298aac4c --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -0,0 +1,128 @@ +'use client'; + +import Link from 'next/link'; + +import { DEFAULT_CODING_HARNESS, type TaskPhase } from '@roomote/types'; + +import { + Button, + ErrorState, + ExternalLink, + Skeleton, +} from '@/components/system'; +import { FramedSurface } from '@/components/layout'; + +import { ArtifactLinkProvider } from '../../task/[taskId]/hooks/ArtifactLinkProvider'; +import { HistoricalSandboxProvider } from '../../task/[taskId]/hooks/HistoricalSandboxProvider'; +import { SandboxProvider } from '../../task/[taskId]/hooks/SandboxProvider'; +import { useTaskMessageEnvelopes } from '../../task/[taskId]/hooks/use-task-message-envelopes'; +import { + useTaskSession, + type TaskSession, +} from '../../task/[taskId]/hooks/use-task-session'; +import { Messages } from '../../task/[taskId]/Messages'; +import { SidePanelHeader } from '../../task/[taskId]/sidebar-panels/SidePanelHeader'; + +function NestedTaskTranscript({ session }: { session: TaskSession }) { + const history = useTaskMessageEnvelopes(session.taskId); + + if (session.isSessionLoading) { + return ( +
+ + + +
+ ); + } + + if ( + session.sessionState === 'error' || + session.sessionState === 'not-found' + ) { + return ; + } + + if (!session.taskRun) { + return ; + } + + const transcript = ( + + + + ); + + if ( + session.sessionState === 'historical' || + session.sessionState === 'resuming' || + session.sessionState === 'boot-failed' + ) { + return ( + + {transcript} + + ); + } + + return ( + + {transcript} + + ); +} + +export function NestedTaskSidePanel({ + taskId, + onClose, +}: { + taskId: string; + onClose: () => void; +}) { + const session = useTaskSession(taskId, { refetchInterval: 2_000 }); + const title = session.task?.title?.trim() || 'Task'; + + return ( + + + + Go to task + + + + } + /> +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx new file mode 100644 index 000000000..042215a64 --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useRecentSessions } from '@/hooks/useRecentSessions'; +import { useTelemetry } from '@/hooks/useTelemetry'; +import { useTRPCClient } from '@/trpc/client'; + +export function SessionReadTracker({ sessionId }: { sessionId: string }) { + const trpc = useTRPCClient(); + const { recordVisit } = useRecentSessions(); + const { capture } = useTelemetry(); + + useEffect(() => { + recordVisit(sessionId); + capture('session_opened', { surface: 'web', outcome: 'opened' }); + const markRead = async () => { + if (document.visibilityState !== 'visible') return; + const timeline = await trpc.sessions.timeline.query({ sessionId }); + const last = timeline?.events.findLast((event) => !event.own); + if (!last) return; + await trpc.sessions.markRead.mutate({ + sessionId, + throughEventAt: last.at, + throughEventId: last.id, + }); + }; + void markRead(); + window.addEventListener('focus', markRead); + document.addEventListener('visibilitychange', markRead); + return () => { + window.removeEventListener('focus', markRead); + document.removeEventListener('visibilitychange', markRead); + }; + }, [capture, recordVisit, sessionId, trpc]); + + return null; +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx new file mode 100644 index 000000000..dc1615898 --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx @@ -0,0 +1,169 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; + +export type SessionTaskSummary = { + taskId: string; + title: string; + workflow: string; + state: string; + repositoryName: string | null; + latestOutput: string | null; + inferenceCostMicroUsd: number; + canAccessDetails?: boolean; + latestRun: { + id: number; + status: string; + taskPhase: string | null; + error: string | null; + result: unknown; + } | null; + artifacts: Array<{ + id: string; + path: string; + artifactType: string; + }>; + pullRequests: Array<{ + id: string; + url: string; + number: number | null; + title: string | null; + repository: string | null; + status: string | null; + }>; +}; + +export function SessionTaskCards({ + sessionId, + tasks, +}: { + sessionId: string; + tasks: SessionTaskSummary[]; +}) { + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const cancel = useMutation(trpc.taskRuns.cancel.mutationOptions()); + const retry = useMutation(trpc.taskRuns.retryFailedStart.mutationOptions()); + + if (tasks.length === 0) return null; + + const selectTask = (taskId: string) => { + const params = new URLSearchParams(searchParams); + params.set('task', taskId); + router.replace(`/sessions/${sessionId}?${params.toString()}`); + }; + + return ( +
+

+ Executions +

+
+ {tasks.map((task) => ( + + +
+ + {task.title} + + + {task.state} + +
+
+ +

{task.repositoryName ?? task.workflow}

+ {task.latestRun?.error ? ( +

+ {task.latestRun.error} +

+ ) : null} + {task.latestOutput ? ( +

{task.latestOutput}

+ ) : null} +

+ ${(task.inferenceCostMicroUsd / 1_000_000).toFixed(4)} inference +

+ {task.canAccessDetails === false ? ( +

Execution details require task access.

+ ) : null} +
+ + {task.canAccessDetails === false ? null : task.state === + 'active' ? ( + + ) : task.state === 'failed' ? ( + + ) : null} + {task.canAccessDetails === false ? null : ( + <> + + + + )} + +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx index 9e21695c6..4614115cb 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -1,23 +1,89 @@ import { useState, type ReactNode } from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { SandboxLayoutContext } from '../../use-sandbox-layout'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; -const { useMediaQueryMock } = vi.hoisted(() => ({ - useMediaQueryMock: vi.fn(), -})); +const { useMediaQueryMock, sessionQueryState, fastTaskQueryState } = vi.hoisted( + () => ({ + useMediaQueryMock: vi.fn(), + sessionQueryState: { data: null as unknown }, + fastTaskQueryState: { data: null as unknown }, + }), +); vi.mock('usehooks-ts', () => ({ useMediaQuery: useMediaQueryMock, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { models: [{ id: 'model-1', displayName: 'Model One' }] }, }), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sessions: { + byId: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['sessions', 'byId', input.sessionId], + queryFn: async () => sessionQueryState.data, + ...options, + }), + }, + }, + fastSessions: { + tasks: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['fastSessions', 'tasks', input.sessionId], + queryFn: async () => fastTaskQueryState.data, + ...options, + }), + }, + }, + }), +})); + +vi.mock('./NestedTaskSidePanel', () => ({ + NestedTaskSidePanel: ({ taskId }: { taskId: string }) => ( +
Nested panel {taskId}
+ ), +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + prompt, + onOpen, + }: { + taskId: string; + prompt: string | null; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + const session: SessionInfo = { id: 'session-1', ownerName: 'Test User', @@ -25,8 +91,11 @@ const session: SessionInfo = { ownerImageUrl: null, surface: 'slack', model: 'model-1', + reasoningEffort: null, inferenceCostMicroUsd: 1_000_000, createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'needs_input', + tasks: [], }; function SandboxLayoutProvider({ children }: { children: ReactNode }) { @@ -45,15 +114,48 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { ); } -function renderWorkspace({ isMobile }: { isMobile: boolean }) { +function renderWorkspace({ + isMobile, + children =
Session transcript
, + sessionOverride, + queriedTasks, + queriedFastTasks, +}: { + isMobile: boolean; + children?: ReactNode; + sessionOverride?: Partial; + queriedTasks?: SessionInfo['tasks']; + queriedFastTasks?: Array< + Pick + >; +}) { useMediaQueryMock.mockReturnValue(!isMobile); + const initialSession = { ...session, ...sessionOverride }; + sessionQueryState.data = { + ...initialSession, + tasks: queriedTasks ?? initialSession.tasks, + }; + fastTaskQueryState.data = queriedFastTasks ?? initialSession.taskCards ?? []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - - -
Session transcript
-
-
, + + + {children} + + , + ); +} + +function OpenNestedTask() { + const openTaskPanel = useOpenSessionTaskPanel(); + + return ( + ); } @@ -68,8 +170,9 @@ describe('SessionWorkspace', () => { expect(screen.queryByText('Session transcript')).not.toBeInTheDocument(); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); + expect(screen.getByText('needs input')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Close session info' }), ).toBeNull(); @@ -89,7 +192,7 @@ describe('SessionWorkspace', () => { fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); }); @@ -103,4 +206,84 @@ describe('SessionWorkspace', () => { screen.getByRole('button', { name: 'Close session info' }), ).toBeInTheDocument(); }); + + it('disables the Tasks panel button until the session has a task', () => { + renderWorkspace({ isMobile: false }); + + expect(screen.getByRole('button', { name: 'Tasks' })).toBeDisabled(); + }); + + it('lists session tasks with delegated task cards', () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { + tasks: [ + { + taskId: 'task-1', + title: 'Update homepage background', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }, + ], + }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: 'View coding task: Update homepage background', + }), + ); + + expect(screen.getByText('Nested panel task-1')).toBeInTheDocument(); + }); + + it('enables and populates the Tasks panel from refreshed session tasks', async () => { + const delegatedTask = { + taskId: 'task-2', + title: 'Refreshed coding task', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }; + renderWorkspace({ + isMobile: false, + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [delegatedTask], + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect( + screen.getByRole('button', { + name: 'View coding task: Refreshed coding task', + }), + ).toBeInTheDocument(); + }); + + it('opens delegated tasks in the existing session side-panel slot', () => { + renderWorkspace({ isMobile: false, children: }); + + fireEvent.click(screen.getByRole('button', { name: 'Open child' })); + + expect(screen.getByText('Nested panel child-1')).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 7c7d6cc3e..ddf049d09 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,27 +1,53 @@ 'use client'; -import { useState, type ReactNode } from 'react'; -import { formatDistanceToNow } from 'date-fns'; +import Link from 'next/link'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { getReasoningEffortLabel, type ReasoningEffort } from '@roomote/types'; import { formatInferenceCost, getUserDisplayName } from '@/lib'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; -import { WorkspaceSurface } from '@/components/layout'; +import { useTRPC } from '@/trpc/client'; +import { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { ArrowLeftFromLine, Avatar, BasicTooltip, + Badge, + BrandIcon, + Brain, Button, + Calendar, DollarSign, + Globe, Info, + Slack, + X, + Rows4, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@/components/system'; +import type { SessionTaskSummary } from './SessionTaskCards'; import { SandboxSidePanelHeader } from '../../SandboxSidePanelHeader'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../SandboxInfoPanel'; import { ResponsiveWorkspacePanels, SandboxSideActions, } from '../../SandboxWorkspacePanels'; import { useSandboxLayout } from '../../use-sandbox-layout'; +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; +import { OpenSessionTaskPanelContext } from './session-task-panel-context'; +import { DelegatedTaskCard } from '../../task/[taskId]/messages/acp/DelegatedTaskCard'; export type SessionInfo = { id: string; @@ -31,25 +57,194 @@ export type SessionInfo = { surface: string; /** Effective model for the session's turns (stored override or default). */ model: string | null; + reasoningEffort: ReasoningEffort | null; inferenceCostMicroUsd: number; createdAt: Date; + status: string | null; + tasks: SessionTaskSummary[]; + taskSource?: 'unified' | 'fast'; + taskCards?: Array>; }; const SURFACE_LABELS: Record = { slack: 'Slack', + linear: 'Linear', + github: 'GitHub', + gitlab: 'GitLab', + gitea: 'Gitea', + bitbucket: 'Bitbucket', + ado: 'Azure DevOps', discord: 'Discord', - teams: 'Microsoft Teams', + teams: 'Teams', telegram: 'Telegram', automation: 'Automation', web: 'Web', }; -function InfoRow({ label, children }: { label: string; children: ReactNode }) { +type SessionSurfaceBrandIcon = + | 'linear' + | 'github' + | 'gitlab' + | 'gitea' + | 'bitbucket' + | 'ado' + | 'discord' + | 'teams' + | 'telegram'; + +const SURFACE_BRAND_ICONS: Partial> = { + linear: 'linear', + github: 'github', + gitlab: 'gitlab', + gitea: 'gitea', + bitbucket: 'bitbucket', + ado: 'ado', + discord: 'discord', + teams: 'teams', + telegram: 'telegram', +}; + +function getSessionStatusVariant(status: string) { + if (status === 'active') return 'success'; + if (status === 'needs_input') return 'warning'; + if (status === 'blocked') return 'destructive'; + return 'secondary'; +} + +function SessionTaskPanel({ + sessionId, + task, + tasks, + onSelect, + onClose, +}: { + sessionId: string; + task: SessionTaskSummary; + tasks: SessionTaskSummary[]; + onSelect: (taskId: string) => void; + onClose: () => void; +}) { return ( - - {label} - {children} - + <> +
+

Execution details

+ + + +
+
+ {tasks.length > 1 ? ( + + ) : null} +
+

{task.title}

+

{task.state}

+ {task.repositoryName ? ( +

{task.repositoryName}

+ ) : null} +
+ {task.canAccessDetails === false ? ( +

+ Execution details require task access. +

+ ) : null} + {task.latestRun?.error ? ( +
+ {task.latestRun.error} +
+ ) : null} + {task.pullRequests.length ? ( +
+

Pull requests

+ {task.pullRequests.map((pullRequest) => ( + + {pullRequest.repository}#{pullRequest.number} + + ))} +
+ ) : null} + {task.artifacts.length ? ( +
+

Artifacts

+ {task.artifacts.map((artifact) => ( + + {artifact.path} + + ))} +
+ ) : null} + {task.canAccessDetails === false ? null : ( + + )} +
+ + ); +} + +function SessionTasksPanel({ + tasks, + onOpenTask, + onClose, +}: { + tasks: Array>; + onOpenTask: (taskId: string) => void; + onClose: () => void; +}) { + return ( + + +
+ {tasks.map((task) => ( + + ))} +
+
); } @@ -70,51 +265,90 @@ function SessionInfoPanel({ ? (modelData?.models.find(({ id }) => id === session.model)?.displayName ?? session.model) : null; + const modelAndReasoningLabel = [ + modelLabel ?? 'Default model', + session.reasoningEffort + ? getReasoningEffortLabel(session.reasoningEffort) + : null, + ] + .filter(Boolean) + .join(' • '); const inferenceCostLabel = formatInferenceCost(session.inferenceCostMicroUsd); + const surfaceLabel = SURFACE_LABELS[session.surface] ?? session.surface; + const surfaceBrandIcon = SURFACE_BRAND_ICONS[session.surface]; return ( - <> - + -
- - - - - - {ownerDisplayName} - - - {modelLabel ?? 'Default model'} - - - - {inferenceCostLabel} + > + + + + + {ownerDisplayName} + + + + + + {modelAndReasoningLabel} + + + + + + {inferenceCostLabel} + + + + + + + {session.createdAt.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + })} - - - - - {formatDistanceToNow(session.createdAt, { addSuffix: true })} - - - - - {SURFACE_LABELS[session.surface] ?? session.surface} - - -
-
- + + + + + {session.surface === 'slack' ? ( + + ) : surfaceBrandIcon ? ( + + ) : ( + + )} + {surfaceLabel} + + + {session.status ? ( + + + {session.status.replace('_', ' ')} + + + ) : null} + +
+ ); } @@ -126,53 +360,150 @@ export function SessionWorkspace({ children: ReactNode; }) { const [isInfoOpen, setIsInfoOpen] = useState(false); + const [isTasksOpen, setIsTasksOpen] = useState(false); + const [nestedTaskId, setNestedTaskId] = useState(null); + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isFastTaskSource = session.taskSource === 'fast'; + const { data: currentSession } = useQuery( + trpc.sessions.byId.queryOptions( + { sessionId: session.id }, + { + enabled: !isFastTaskSource, + refetchInterval: 2_000, + }, + ), + ); + const { data: currentFastTasks } = useQuery( + trpc.fastSessions.tasks.queryOptions( + { sessionId: session.id }, + { + enabled: isFastTaskSource, + refetchInterval: 2_000, + }, + ), + ); + const sessionTasks = currentSession?.tasks ?? session.tasks; + const taskCards = isFastTaskSource + ? (currentFastTasks ?? session.taskCards ?? session.tasks) + : sessionTasks; + const selectedTaskId = searchParams.get('task'); + const selectedTask = sessionTasks.find( + (task) => task.taskId === selectedTaskId, + ); + const panelOpen = + isInfoOpen || isTasksOpen || Boolean(selectedTask) || Boolean(nestedTaskId); + + const selectTask = useCallback( + (taskId: string | null) => { + const params = new URLSearchParams(searchParams); + if (taskId) params.set('task', taskId); + else params.delete('task'); + const query = params.toString(); + router.replace(`/sessions/${session.id}${query ? `?${query}` : ''}`); + }, + [router, searchParams, session.id], + ); + + useEffect(() => { + if (!isTasksOpen && !selectedTaskId && session.tasks.length === 1) { + selectTask(session.tasks[0]!.taskId); + } + }, [isTasksOpen, selectTask, selectedTaskId, session.tasks]); + + const openTaskPanel = useCallback( + (taskId: string) => { + setIsInfoOpen(false); + setIsTasksOpen(false); + setNestedTaskId(taskId); + selectTask(null); + }, + [selectTask], + ); + const closePanel = () => { + setIsInfoOpen(false); + setIsTasksOpen(false); + setNestedTaskId(null); + selectTask(null); + }; + const panelContent = nestedTaskId ? ( + + ) : selectedTask ? ( + + ) : isTasksOpen ? ( + + ) : ( + + ); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); return ( - - setIsInfoOpen(false)} - > - setIsInfoOpen((previous) => !previous)} - /> - - {!isSidebarVisible && !isInfoOpen ? ( - - - - ) : null} - - } - > - setIsInfoOpen(false)} - /> + + + + { + setIsTasksOpen(false); + setNestedTaskId(null); + selectTask(null); + setIsInfoOpen((previous) => !previous); + }} + /> + { + setNestedTaskId(null); + setIsInfoOpen(false); + selectTask(null); + setIsTasksOpen((previous) => !previous); + }} + /> + + {!isSidebarVisible && !panelOpen ? ( + + + + ) : null} + } - /> - + > + + + ); } diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx index 90f947b1c..6726138a0 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -1,22 +1,37 @@ import type { ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -const { authorizeMock, getFastSessionByIdMock, transcriptMock } = vi.hoisted( - () => ({ - authorizeMock: vi.fn(), - getFastSessionByIdMock: vi.fn(), - transcriptMock: vi.fn( - ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( -
{footer}
- ), +const { + authorizeMock, + getFastSessionByIdMock, + getSessionByIdCommandMock, + transcriptMock, + sessionWorkspaceMock, +} = vi.hoisted(() => ({ + authorizeMock: vi.fn(), + getFastSessionByIdMock: vi.fn(), + getSessionByIdCommandMock: vi.fn(), + transcriptMock: vi.fn( + ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( +
{footer}
), - }), -); + ), + sessionWorkspaceMock: vi.fn(({ children }: { children: ReactNode }) => ( +
{children}
+ )), +})); vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, })); +vi.mock('@/trpc/commands/sessions', () => ({ + getSessionByIdCommand: getSessionByIdCommandMock, +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: () => ({ isSidebarVisible: true, @@ -35,10 +50,21 @@ vi.mock('@/components/layout', () => ({ vi.mock('./FastSessionTranscript', () => ({ FastSessionTranscript: transcriptMock, })); +vi.mock('./SessionWorkspace', () => ({ + SessionWorkspace: sessionWorkspaceMock, +})); +vi.mock('./SessionReadTracker', () => ({ + SessionReadTracker: () => null, +})); import SessionDetailPage from './page'; describe('Fast session detail page', () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionByIdCommandMock.mockResolvedValue(null); + }); + it('uses the shared task workspace and renders supported session data', async () => { authorizeMock.mockResolvedValue({ success: true, @@ -156,7 +182,66 @@ describe('Fast session detail page', () => { sessionId: 'session-2', canReply: true, initialTitle: 'Rotate the API keys', - fallbackTitle: 'Session', + fallbackTitle: 'New session', + }), + undefined, + ); + }); + + it('loads linked tasks for Fast session URLs when Sessions UI is disabled', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + featureFlags: { sessions_ui: false }, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: 'unified-session-1', + title: 'Session title', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'slack', + fastConversationId: 'fast-session-3', + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'active', + tasks: [ + { + taskId: 'task-1', + title: 'Delegated task', + }, + ], + }); + getFastSessionByIdMock.mockResolvedValue({ + id: 'fast-session-3', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ sessionId: 'fast-session-3' }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'fast-session-3', + ); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: 'unified-session-1', + tasks: [expect.objectContaining({ taskId: 'task-1' })], + }), }), undefined, ); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index c042a4893..dc9d0e2f7 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -8,10 +8,18 @@ import { } from '@roomote/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessionById } from '@/lib/server/fast-sessions'; +import { + getFastSessionById, + getFastSessionTasks, +} from '@/lib/server/fast-sessions'; +import { getSessionByIdCommand } from '@/trpc/commands/sessions'; +import { Badge } from '@/components/system'; +import { WorkspaceHeader } from '@/components/layout'; import { FastSessionTranscript } from './FastSessionTranscript'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { SessionTaskCards } from './SessionTaskCards'; +import { SessionReadTracker } from './SessionReadTracker'; export default async function SessionDetailPage({ params, @@ -26,7 +34,99 @@ export default async function SessionDetailPage({ notFound(); } - const session = await getFastSessionById(authorizedUser, sessionId); + const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); + const session = unifiedSession?.fastConversationId + ? await getFastSessionById( + authorizedUser, + unifiedSession.fastConversationId, + ) + : unifiedSession + ? null + : await getFastSessionById(authorizedUser, sessionId); + if (unifiedSession) { + const modelEnv: Record = + await resolveEffectiveModelRuntimeEnv().catch(() => ({})); + const defaultModelId = + modelEnv.R_ORCHESTRATION_MODEL || modelEnv.R_MODEL || null; + const rawDefaultEffort = modelEnv.R_ORCHESTRATION_MODEL_REASONING_EFFORT; + const defaultReasoningEffort = REASONING_EFFORT_VALUES.includes( + rawDefaultEffort as ReasoningEffort, + ) + ? (rawDefaultEffort as ReasoningEffort) + : null; + const sessionInfo: SessionInfo = { + id: unifiedSession.id, + ownerName: unifiedSession.ownerName, + ownerEmail: unifiedSession.ownerEmail, + ownerImageUrl: unifiedSession.ownerImageUrl, + surface: unifiedSession.sourceSurface, + model: session?.model ?? defaultModelId, + reasoningEffort: session?.reasoningEffort ?? defaultReasoningEffort, + inferenceCostMicroUsd: unifiedSession.inferenceCostMicroUsd, + createdAt: unifiedSession.createdAt, + status: unifiedSession.status, + tasks: unifiedSession.tasks, + }; + const statusVariant = + unifiedSession.status === 'active' + ? 'success' + : unifiedSession.status === 'needs_input' + ? 'warning' + : unifiedSession.status === 'blocked' + ? 'destructive' + : 'secondary'; + const taskCards = ( + + ); + + return ( + + +
+ {session ? ( + + {unifiedSession.status.replace('_', ' ')} + + } + timelineExtras={taskCards} + /> + ) : ( + <> + +

+ {unifiedSession.title} +

+ + {unifiedSession.status.replace('_', ' ')} + +
+
+
{taskCards}
+
+ + )} +
+
+ ); + } if (!session) { notFound(); } @@ -51,15 +151,20 @@ export default async function SessionDetailPage({ ownerImageUrl: session.ownerImageUrl, surface: session.surface, model: session.model ?? defaultModelId, + reasoningEffort: session.reasoningEffort ?? defaultReasoningEffort, inferenceCostMicroUsd: session.inferenceCostMicroUsd, createdAt: session.createdAt, + status: null, + tasks: [], + taskSource: 'fast', + taskCards: (await getFastSessionTasks(authorizedUser, session.id)) ?? [], }; const initialUserMessage = session.messages.find( (message) => message.role === 'user', ); const fallbackTitle = getTextFromContentBlocks(initialUserMessage?.contentBlocks ?? [])?.trim() || - 'Session'; + 'New session'; return ( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts new file mode 100644 index 000000000..8fd19b8cf --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export const OpenSessionTaskPanelContext = createContext< + ((taskId: string) => void) | null +>(null); + +export function useOpenSessionTaskPanel() { + return useContext(OpenSessionTaskPanelContext); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx index 8dd0401c4..78cde02ff 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx @@ -1,12 +1,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const { useSandboxLayoutMock, useTRPCMock, updateTitleMutationMock } = - vi.hoisted(() => ({ - useSandboxLayoutMock: vi.fn(), - useTRPCMock: vi.fn(), - updateTitleMutationMock: vi.fn(async () => undefined), - })); +const { + useSandboxLayoutMock, + useTRPCMock, + updateTitleMutationMock, + parentSessionQueryMock, +} = vi.hoisted(() => ({ + useSandboxLayoutMock: vi.fn(), + useTRPCMock: vi.fn(), + updateTitleMutationMock: vi.fn(async () => undefined), + parentSessionQueryMock: vi.fn(), +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: useSandboxLayoutMock, @@ -16,6 +21,10 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); +vi.mock('./TaskSessionReadTracker', () => ({ + TaskSessionReadTracker: () => null, +})); + vi.mock('@/components/sandbox', () => ({ WorkspaceBadge: ({ environmentId, @@ -78,6 +87,10 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + parentSessionQueryMock.mockResolvedValue({ + sessionId: 'session-1', + title: 'Parent Session', + }); useSandboxLayoutMock.mockReturnValue({ isSidebarVisible: true, @@ -93,6 +106,18 @@ describe('Header', () => { ], }, }, + sessions: { + forTask: { + queryOptions: ( + _input: { taskId: string }, + options?: { enabled?: boolean }, + ) => ({ + queryKey: ['sessions.forTask'], + queryFn: parentSessionQueryMock, + enabled: options?.enabled, + }), + }, + }, tasks: { updateTitle: { mutationOptions: () => ({ @@ -142,6 +167,36 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); + it('renders the parent session link regardless of the Sessions UI flag', async () => { + renderHeader(); + + expect( + await screen.findByRole('link', { name: 'Parent Session' }), + ).toHaveAttribute('href', '/sessions/session-1?task=task-123'); + expect(screen.getByRole('link', { name: /Go to session/ })).toHaveAttribute( + 'href', + '/sessions/session-1?task=task-123', + ); + }); + + it('links to the Fast session when the task has no unified session', async () => { + parentSessionQueryMock.mockResolvedValue(null); + + renderHeader({ + taskRun: { + payload: { + environmentId: 'env-1', + fastAgentSessionId: '00000000-0000-4000-8000-000000000001', + }, + harness: 'opencode-server', + } as never, + }); + + expect( + await screen.findByRole('link', { name: /Go to session/ }), + ).toHaveAttribute('href', '/sessions/00000000-0000-4000-8000-000000000001'); + }); + it('refreshes task lists after renaming a task', async () => { const { queryClient } = renderHeader(); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 282df1030..749a07a0a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -1,17 +1,26 @@ 'use client'; import { useEffect, useState, type KeyboardEvent } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { ArrowLeftFromLine, Button, + ExternalLink, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, } from '@/components/system'; import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; @@ -20,6 +29,7 @@ import { useTRPC } from '@/trpc/client'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; +import { TaskSessionReadTracker } from './TaskSessionReadTracker'; interface HeaderProps { session: TaskSession; @@ -28,15 +38,29 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); + const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); + const parentSessionOptions = trpc.sessions?.forTask?.queryOptions({ + taskId, + }) ?? { + queryKey: ['sessions', 'for-task', 'disabled', taskId], + queryFn: async () => null, + enabled: false, + }; + const { data: parentSession } = useQuery(parentSessionOptions); const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; const pullRequests = taskRun?.pullRequests ?? []; + const sessionHref = parentSession + ? `/sessions/${parentSession.sessionId}?task=${taskId}` + : taskRun?.payload?.fastAgentSessionId + ? `/sessions/${taskRun.payload.fastAgentSessionId}` + : null; const badges = [ (environmentId || repo) && ( @@ -150,21 +174,65 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { }; const title = task?.title || 'Untitled task'; + const returnTo = searchParams?.get('returnTo'); + const safeReturnTo = + returnTo?.startsWith('/sessions') && !returnTo.startsWith('//') + ? returnTo + : '/sessions'; return ( <> - -

- {title} -

+ {parentSession ? ( + + ) : null} + + {parentSession ? ( + + + + + Sessions + + + + + + + {parentSession.title} + + + + + + + {title} + + + + + ) : ( +

+ {title} +

+ )} {badges.length > 0 && (
{badges.map((badge, index) => ( @@ -174,6 +242,14 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { ))}
)} + {sessionHref ? ( + + ) : null} {!isSidebarVisible && ( + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts index 29b7fd169..0ea0c5827 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts @@ -2014,4 +2014,27 @@ describe('buildAcpRenderBlocks', () => { }, }); }); + + it('keeps multiple delegated tasks as standalone cards when requested', () => { + const delegatedTask = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'launch_task', + kind: 'tool', + mcp: false, + payload: { + toolName: 'launch_task', + output: JSON.stringify({ success: true, taskId: id }), + }, + }); + + const entries = buildAcpRenderBlocks( + [delegatedTask('child-1', 1), delegatedTask('child-2', 2)], + { keepDelegatedTasksVisible: true }, + ); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts index c899e451e..f1391c2f6 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts @@ -10,6 +10,7 @@ import type { import type { AcpRenderBlock } from './render-blocks'; import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; +import { getDelegatedTaskDetails } from './delegated-task'; const COLLAPSIBLE_ACP_MESSAGE_KINDS = [ 'reasoning', @@ -42,6 +43,7 @@ interface BuildAcpActivityRenderBlocksOptions { displayMode?: 'default' | 'narration'; hasLeadingTextBoundary?: boolean; collapseLeadingActivity?: boolean; + keepDelegatedTasksVisible?: boolean; } function isToolMessage( @@ -152,6 +154,7 @@ function isLivePartialBlock(block: AcpRenderBlock): boolean { export function isActivityCollapsibleBlock( block: AcpRenderBlock, artifacts?: readonly TaskArtifact[] | null, + keepDelegatedTasksVisible = false, ): boolean { // Keep in-flight reasoning/tool rows outside default-closed groups so current // activity stays visible without a manual expand. @@ -179,6 +182,14 @@ export function isActivityCollapsibleBlock( return false; } + if ( + keepDelegatedTasksVisible && + isToolMessage(msg) && + getDelegatedTaskDetails(msg) + ) { + return false; + } + return true; } @@ -215,7 +226,11 @@ export function buildAcpActivityRenderBlocks( if ( !hasLeftTextBoundary || - !isActivityCollapsibleBlock(current, options.artifacts) + !isActivityCollapsibleBlock( + current, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { groupedBlocks.push(current); hasLeftTextBoundary = false; @@ -228,7 +243,11 @@ export function buildAcpActivityRenderBlocks( while ( activityEnd < blocks.length && - isActivityCollapsibleBlock(blocks[activityEnd]!, options.artifacts) + isActivityCollapsibleBlock( + blocks[activityEnd]!, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { activityEnd += 1; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts new file mode 100644 index 000000000..488e97bc8 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts @@ -0,0 +1,50 @@ +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface DelegatedTaskDetails { + taskId: string; + prompt: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function getDelegatedTaskDetails( + msg: ToolMessage, +): DelegatedTaskDetails | null { + const toolName = (msg.data.toolName ?? msg.data.mcpToolName) + ?.trim() + .toLowerCase(); + + if (msg.kind !== 'tool_result' || toolName !== 'launch_task') { + return null; + } + + try { + const parsed = asRecord(JSON.parse(msg.data.output)); + const result = asRecord(parsed?.result) ?? asRecord(parsed?.data) ?? parsed; + const taskId = result?.taskId; + + if (typeof taskId !== 'string' || taskId.length === 0) { + return null; + } + + const rawInput = asRecord( + (msg.data as unknown as Record).rawInput, + ); + const args = asRecord(rawInput?.arguments); + const prompt = args?.prompt; + + return { + taskId, + prompt: + typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts index 3cedf9ef0..b82defe01 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts @@ -20,6 +20,7 @@ import { isSubagentToolPayload, } from './subagent-tool'; import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; +import { getDelegatedTaskDetails } from './delegated-task'; export type ExplorationStepKind = 'list' | 'read' | 'search'; @@ -113,6 +114,7 @@ interface BuildAcpRenderBlocksOptions { initialPrompt?: Pick | null; shouldHideFirstMessage?: boolean; showInternalMessages?: boolean; + keepDelegatedTasksVisible?: boolean; suppressedMessageIds?: ReadonlySet; } @@ -759,7 +761,10 @@ function resolveMessageRenderState( return { visibility: 'render', - groupKey: resolveToolGroupKey(msg), + groupKey: + options.keepDelegatedTasksVisible && getDelegatedTaskDetails(msg) + ? null + : resolveToolGroupKey(msg), }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx index 61d3e12ff..d40f13449 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx @@ -52,6 +52,11 @@ import { useTaskSummary, } from '../hooks'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../../SandboxInfoPanel'; import { SidePanelHeader } from './SidePanelHeader'; import { getTaskParticipants } from './task-participants'; @@ -299,296 +304,270 @@ export function TaskInfoPanel({ PRODUCT_NAME; return ( - <> - -
-
- - - - - - - - {participants.length > 0 && ( - - - - - )} - - {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( - - - - - )} - - - - - - - {taskModelLabel && ( - - - - - )} - - - - - - - {showRuntimeRow && ( - - - - - )} - - {(taskRun.pullRequests?.length ?? 0) > 0 ? ( - - - - - ) : taskRun.prRepo && taskRun.prNumber ? ( - - - + + + + + {participants.length > 0 && ( + + + - - ) : null} - - {linkedWorkItems.length > 0 ? ( - - - - - ) : null} - - - - - - - - - - - -
- Creator - - {task.user && task.attributionKind === 'user' ? ( - <> - {task.user.imageUrl ? ( - {taskCreatorDisplayName} - ) : null} - {taskCreatorDisplayName} - - ) : ( - taskCreatorDisplayName - )} -
- Participants - -
- {participants.map((participant) => ( - - - {participant.displayName} - - ))} -
-
- Workspace - - -
- Sandbox Provider - - - - {sandboxProviderLabel} - -
- Model - - - - {taskModelLabel} - {taskRun.payload?.modelRoleOverrides && ( - - Customized - - )} - -
- Inference Cost - - - - {inferenceCostLabel} - -
- Runtime - - - - - {HARNESS_LABELS[effectiveHarness]} - - -
- Pull Requests - -
- {taskRun.pullRequests?.map((pullRequest) => ( - - ))} -
-
- Pull Request - - } + > + +
Creator + {task.user && task.attributionKind === 'user' ? ( + <> + {task.user.imageUrl ? ( + {taskCreatorDisplayName} + ) : null} + {taskCreatorDisplayName} + + ) : ( + taskCreatorDisplayName + )} +
+ Participants + +
+ {participants.map((participant) => ( + + -
- Linked Work - -
- {linkedWorkItems.map((item, index) => ( - - ))} -
-
- Started At - - - - - {formatStartedAt(taskRun.startedAt)} - + {participant.displayName} -
- Started From - - - {startedFrom.brandIcon ? ( - startedFrom.brandIcon === 'slack' ? ( - - ) : ( - - ) - ) : ( - - )} - {startedFrom.label} - -
- - {taskRunError && ( -
-
-

Last Error

- + ))}
-

- {taskRunError} -

-
- )} + + + )} + + {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( + + Workspace + + + + + )} + + + + Sandbox Provider + + + + + {sandboxProviderLabel} + + + + + {taskModelLabel && ( + + Model + + + + {taskModelLabel} + {taskRun.payload?.modelRoleOverrides && ( + + Customized + + )} + + + + )} + + + + + {inferenceCostLabel} + + + + {showRuntimeRow && ( + + Runtime + + + + + {HARNESS_LABELS[effectiveHarness]} + + + + + )} + + {(taskRun.pullRequests?.length ?? 0) > 0 ? ( + + + Pull Requests + + +
+ {taskRun.pullRequests?.map((pullRequest) => ( + + ))} +
+ + + ) : taskRun.prRepo && taskRun.prNumber ? ( + + + Pull Request + + + + + + ) : null} - {summaryEnabled && ( -
-
-

Summary

+ {linkedWorkItems.length > 0 ? ( + + + Linked Work + + +
+ {linkedWorkItems.map((item, index) => ( + + ))}
+ + + ) : null} - {isLoadingSummary ? ( -
- - Generating... -
- ) : summary ? ( - <> - {isSummaryStale && ( -
- New messages since last summarized. - -
- )} -
- - {summary} - -
- - ) : summaryErrorMessage ? ( -
-

{summaryErrorMessage}

+ + + + + {formatStartedAt(taskRun.startedAt)} + + + + + + + {startedFrom.brandIcon ? ( + startedFrom.brandIcon === 'slack' ? ( + + ) : ( + + ) + ) : ( + + )} + {startedFrom.label} + + + + + {taskRunError && ( +
+
+

Last Error

+ +
+

+ {taskRunError} +

+
+ )} + + {summaryEnabled && ( +
+
+

Summary

+
+ + {isLoadingSummary ? ( +
+ + Generating... +
+ ) : summary ? ( + <> + {isSummaryStale && ( +
+ New messages since last summarized.
- ) : null} + )} +
+ + {summary} + +
+ + ) : summaryErrorMessage ? ( +
+

{summaryErrorMessage}

+
- )} + ) : null}
-
- + )} + ); } diff --git a/apps/web/src/components/layout/CommandPalette.client.test.tsx b/apps/web/src/components/layout/CommandPalette.client.test.tsx index 913c854a9..3fe9f5198 100644 --- a/apps/web/src/components/layout/CommandPalette.client.test.tsx +++ b/apps/web/src/components/layout/CommandPalette.client.test.tsx @@ -201,10 +201,10 @@ describe('CommandPalette', () => { it('navigates using static navigation items', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + fireEvent.click(screen.getByRole('button', { name: 'Sessions' })); expect(setOpen).toHaveBeenCalledWith(false); - expect(push).toHaveBeenCalledWith('/tasks'); + expect(push).toHaveBeenCalledWith('/sessions'); }); it('lists navigation items in the expected order', () => { @@ -216,7 +216,7 @@ describe('CommandPalette', () => { .filter((label): label is string => [ 'New Task', - 'Tasks', + 'Sessions', 'Automations', 'Analytics', 'Settings', @@ -224,7 +224,7 @@ describe('CommandPalette', () => { ].includes(label ?? ''), ); - expect(navItems).toEqual(['New Task', 'Tasks', 'Settings', 'Help']); + expect(navItems).toEqual(['New Task', 'Sessions', 'Settings', 'Help']); }); it('lets admins find and open recurring automations', () => { @@ -247,7 +247,7 @@ describe('CommandPalette', () => { .filter((label): label is string => [ 'New Task', - 'Tasks', + 'Sessions', 'Automations', 'Analytics', 'Settings', @@ -256,7 +256,7 @@ describe('CommandPalette', () => { ); expect(navItems).toEqual([ 'New Task', - 'Tasks', + 'Sessions', 'Automations', 'Analytics', 'Settings', diff --git a/apps/web/src/components/layout/CommandPalette.tsx b/apps/web/src/components/layout/CommandPalette.tsx index b45af7eb7..99ff4caa6 100644 --- a/apps/web/src/components/layout/CommandPalette.tsx +++ b/apps/web/src/components/layout/CommandPalette.tsx @@ -111,7 +111,11 @@ function AuthorizedCommandPalette() { const navItems = useMemo(() => { const items: NavItem[] = [ { icon: Plus, label: 'New Task', href: '/' }, - { icon: GalleryVerticalEnd, label: 'Tasks', href: '/tasks' }, + { + icon: GalleryVerticalEnd, + label: 'Sessions', + href: '/sessions', + }, { icon: Settings, label: 'Settings', href: '/settings' }, { icon: HelpCircle, label: 'Help', action: 'contact-support' }, ]; @@ -157,6 +161,15 @@ function AuthorizedCommandPalette() { { enabled: open }, ), ); + const sessionSearchOptions = trpc.sessions?.search?.queryOptions( + { query: debouncedSearch, limit: SEARCH_TASKS_LIMIT }, + { enabled: open && user?.featureFlags?.sessions_ui === true }, + ) ?? { + queryKey: ['sessions', 'search', 'disabled'], + queryFn: async () => null, + enabled: false, + }; + const { data: sessionResults } = useQuery(sessionSearchOptions); // Promote recently-visited tasks to the top of the list const sortedTasks = useMemo(() => { @@ -281,6 +294,23 @@ function AuthorizedCommandPalette() { )} + {(sessionResults?.sessions?.length ?? 0) > 0 ? ( + + {sessionResults!.sessions.map((session) => ( + navigate(`/sessions/${session.id}`)} + > + {session.title} + + {session.executionCount} executions + + + ))} + + ) : null} + {commandGroups.size > 0 && Array.from(commandGroups.entries()).map(([group, cmds]) => ( diff --git a/apps/web/src/components/layout/RouteTitle.tsx b/apps/web/src/components/layout/RouteTitle.tsx index d186a58bc..da7c01229 100644 --- a/apps/web/src/components/layout/RouteTitle.tsx +++ b/apps/web/src/components/layout/RouteTitle.tsx @@ -7,7 +7,7 @@ import { getSettingsTitleForPath } from '@/components/settings/settings-navigati const ROUTE_TITLES: [RegExp, string][] = [ [/^\/analytics$/, 'Analytics'], - [/^\/tasks$/, 'Tasks'], + [/^\/sessions$/, 'Sessions'], [/^\/$/, 'Home'], ]; diff --git a/apps/web/src/components/layout/navigation-items.test.ts b/apps/web/src/components/layout/navigation-items.test.ts index 6d8aab869..f5c6726a2 100644 --- a/apps/web/src/components/layout/navigation-items.test.ts +++ b/apps/web/src/components/layout/navigation-items.test.ts @@ -1,12 +1,12 @@ import { getVisiblePrimaryNavItems } from './navigation-items'; describe('getVisiblePrimaryNavItems', () => { - it('places task history before automations for admins', () => { + it('places sessions before automations for admins', () => { const items = getVisiblePrimaryNavItems({ isAdmin: true }); expect(items.map((item) => item.href)).toEqual([ '/', - '/tasks', + '/sessions', '/automations', '/analytics', ]); @@ -17,7 +17,7 @@ describe('getVisiblePrimaryNavItems', () => { isAdmin: false, }); - expect(items.map((item) => item.href)).toEqual(['/', '/tasks']); + expect(items.map((item) => item.href)).toEqual(['/', '/sessions']); }); it('hides automations from non-admins', () => { @@ -25,6 +25,6 @@ describe('getVisiblePrimaryNavItems', () => { isAdmin: false, }); - expect(items.map((item) => item.href)).toEqual(['/', '/tasks']); + expect(items.map((item) => item.href)).toEqual(['/', '/sessions']); }); }); diff --git a/apps/web/src/components/layout/navigation-items.ts b/apps/web/src/components/layout/navigation-items.ts index da059f972..1df2022dd 100644 --- a/apps/web/src/components/layout/navigation-items.ts +++ b/apps/web/src/components/layout/navigation-items.ts @@ -1,6 +1,5 @@ import { type LucideIcon } from '@/components/system'; -import { ChartColumnIncreasing, House, Zap } from '@/components/system'; -import { Rows4 } from 'lucide-react'; +import { ChartColumnIncreasing, House, Rows4, Zap } from '@/components/system'; interface PrimaryNavItem { icon: LucideIcon; @@ -24,11 +23,11 @@ const PRIMARY_NAV_ITEMS: PrimaryNavItem[] = [ }, { icon: Rows4, - href: '/tasks', - label: 'Tasks', - description: 'View current and past tasks', + href: '/sessions', + label: 'Sessions', + description: 'View conversations and delegated work', matchExact: false, - matchPaths: ['/tasks', '/cloud-agents'], + matchPaths: ['/sessions', '/tasks', '/cloud-agents'], }, { icon: Zap, diff --git a/apps/web/src/hooks/useRecentSessions.ts b/apps/web/src/hooks/useRecentSessions.ts new file mode 100644 index 000000000..b109a171b --- /dev/null +++ b/apps/web/src/hooks/useRecentSessions.ts @@ -0,0 +1,42 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { useAuthorizedUser } from './useUser'; + +const MAX_RECENT_SESSIONS = 20; + +export function useRecentSessions() { + const { userId } = useAuthorizedUser(); + const storageKey = `roomote-recent-sessions:${userId}`; + const [recentSessionIds, setRecentSessionIds] = useState([]); + + useEffect(() => { + try { + const stored = JSON.parse(localStorage.getItem(storageKey) ?? '[]'); + setRecentSessionIds(Array.isArray(stored) ? stored.slice(0, 20) : []); + } catch { + setRecentSessionIds([]); + } + }, [storageKey]); + + const recordVisit = useCallback( + (sessionId: string) => { + setRecentSessionIds((current) => { + const next = [ + sessionId, + ...current.filter((id) => id !== sessionId), + ].slice(0, MAX_RECENT_SESSIONS); + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + } catch { + // Local recents are best-effort. + } + return next; + }); + }, + [storageKey], + ); + + return { recentSessionIds, recordVisit }; +} diff --git a/apps/web/src/lib/server/analytics/index.ts b/apps/web/src/lib/server/analytics/index.ts index 456650d9d..cf003c225 100644 --- a/apps/web/src/lib/server/analytics/index.ts +++ b/apps/web/src/lib/server/analytics/index.ts @@ -23,6 +23,7 @@ import { getCostAnalyticsRows, } from './cost-rows'; import { getTaskAnalyticsRows } from './task-rows'; +import { getSessionAnalyticsRows } from './session-rows'; import { buildPullRequestAnalyticsSummary, getPullRequestAnalyticsRows, @@ -39,6 +40,8 @@ async function getAnalyticsRows( metric: AnalyticsMetric = getDefaultAnalyticsMetric(object), ) { switch (object) { + case 'sessions': + return getSessionAnalyticsRows(auth, timePeriod, now); case 'tasks': return getTaskAnalyticsRows(auth, timePeriod, now, metric); case 'pullRequests': @@ -53,6 +56,17 @@ function getAnalyticsDetailsColumns( metric: AnalyticsMetric = getDefaultAnalyticsMetric(object), ): AnalyticsDetailsColumn[] { switch (object) { + case 'sessions': + return [ + { key: 'date', label: 'Date' }, + { key: 'user', label: 'User' }, + { key: 'source', label: 'Source' }, + { key: 'status', label: 'Status' }, + { key: 'ownerKind', label: 'Owner kind' }, + { key: 'hasExecution', label: 'Has execution' }, + { key: 'sessionTitle', label: 'Session' }, + { key: 'session', label: 'Session Link' }, + ]; case 'tasks': { const columns: AnalyticsDetailsColumn[] = [ { key: 'date', label: 'Date' }, diff --git a/apps/web/src/lib/server/analytics/session-rows.ts b/apps/web/src/lib/server/analytics/session-rows.ts new file mode 100644 index 000000000..9cc0a9d77 --- /dev/null +++ b/apps/web/src/lib/server/analytics/session-rows.ts @@ -0,0 +1,84 @@ +import { + and, + db, + eq, + gte, + sessions, + sessionTasks, + sql, + users, +} from '@roomote/db/server'; + +import type { TimePeriodFilter, UserAuthSuccess } from '@/types'; +import { getUserDisplayName } from '@/lib'; + +import type { AnalyticsRow } from './types'; + +export async function getSessionAnalyticsRows( + _auth: UserAuthSuccess, + timePeriod: TimePeriodFilter | undefined, + now: Date, +): Promise { + const rows = await db + .select({ + id: sessions.id, + title: sessions.title, + ownerName: users.name, + ownerEmail: users.email, + source: sessions.sourceSurface, + ownerKind: sessions.ownerKind, + executionCount: sql`( + select count(*)::int from ${sessionTasks} + where ${sessionTasks.sessionId} = ${sessions.id} + )`, + status: sessions.cachedStatus, + createdAt: sessions.createdAt, + }) + .from(sessions) + .leftJoin(users, eq(users.id, sessions.ownerUserId)) + .where( + and( + eq(sessions.visibility, 'visible'), + timePeriod && timePeriod !== 'all' + ? gte( + sessions.createdAt, + new Date(now.getTime() - timePeriod * 24 * 60 * 60 * 1000), + ) + : undefined, + ), + ); + + return rows.map((row) => { + const owner = + getUserDisplayName({ name: row.ownerName, email: row.ownerEmail }) ?? + 'System'; + const status = row.status ?? 'ready'; + const hasExecution = row.executionCount > 0 ? 'yes' : 'no'; + return { + id: row.id, + timestamp: row.createdAt, + value: 1, + dimensions: { + user: { key: owner, label: owner }, + status: { key: status, label: status.replace('_', ' ') }, + source: { key: row.source, label: row.source }, + ownerKind: { key: row.ownerKind, label: row.ownerKind }, + hasExecution: { key: hasExecution, label: hasExecution }, + }, + details: { + id: row.id, + values: { + date: row.createdAt.toISOString(), + user: owner, + source: row.source, + status, + ownerKind: row.ownerKind, + hasExecution, + sessionTitle: row.title, + session: 'Open', + }, + links: { session: `/sessions/${row.id}` }, + }, + }; + }); +} diff --git a/apps/web/src/lib/server/auth-context.test.ts b/apps/web/src/lib/server/auth-context.test.ts index 524ce83f8..434b2dc43 100644 --- a/apps/web/src/lib/server/auth-context.test.ts +++ b/apps/web/src/lib/server/auth-context.test.ts @@ -209,7 +209,7 @@ describe('authorize', () => { expect(mockUpdateSet).not.toHaveBeenCalled(); }); - it('hydrates an empty feature flag map from stale deployment metadata', async () => { + it('ignores stale metadata and hydrates disabled Sessions flags', async () => { mockDeploymentFindFirst.mockResolvedValue({ metadata: { suggestion_routing: true }, }); @@ -217,7 +217,13 @@ describe('authorize', () => { const result = await authorize(); expect(result.success).toBe(true); - if (result.success) expect(result.featureFlags).toEqual({}); + if (result.success) { + expect(result.featureFlags).toEqual({ + sessions_data: false, + sessions_ui: false, + sessions_comms: false, + }); + } }); it('keeps an unchanged member with incomplete onboarding read-only', async () => { diff --git a/apps/web/src/lib/server/fast-sessions.test.ts b/apps/web/src/lib/server/fast-sessions.test.ts index a4dcdd1e2..6b67fa247 100644 --- a/apps/web/src/lib/server/fast-sessions.test.ts +++ b/apps/web/src/lib/server/fast-sessions.test.ts @@ -2,6 +2,8 @@ import { db, fastAgentConversations, fastAgentMessages, + runFactory, + taskFactory, userFactory, } from '@roomote/db/server'; @@ -9,6 +11,7 @@ import { encodeFastSessionCursor, findAccessibleFastSession, getFastSessionById, + getFastSessionTasks, getFastSessionMessagesSince, getFastSessions, } from './fast-sessions'; @@ -183,6 +186,31 @@ describe('Fast session queries', () => { ).resolves.toMatchObject({ id: session.id, userId: owner.id }); }); + it('lists every task associated with a Fast session', async () => { + const owner = await userFactory.create(); + const session = await createFastSession({ + userId: owner.id, + conversationId: 'tasks-session', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + const delegatedTask = await taskFactory.create({ + title: 'Delegated task', + state: 'active', + }); + await runFactory.create({ + taskId: delegatedTask.id, + payload: { + repo: 'acme/widgets', + description: 'Delegated Fast task', + fastAgentSessionId: session.id, + }, + }); + + await expect( + getFastSessionTasks({ userId: owner.id, isAdmin: false }, session.id), + ).resolves.toEqual([{ taskId: delegatedTask.id, title: 'Delegated task' }]); + }); + it('reads canonical messages in timestamp and turn sequence order', async () => { const owner = await userFactory.create(); const session = await createFastSession({ diff --git a/apps/web/src/lib/server/fast-sessions.ts b/apps/web/src/lib/server/fast-sessions.ts index 96eccfc9a..41a17e5fa 100644 --- a/apps/web/src/lib/server/fast-sessions.ts +++ b/apps/web/src/lib/server/fast-sessions.ts @@ -13,9 +13,13 @@ import { fastAgentConversations, fastAgentMessages, llmUsageEvents, + inArray, + isNull, lt, or, sql, + taskRuns, + tasks, users, } from '@roomote/db/server'; import type { FastAgentMessage } from '@roomote/db'; @@ -24,6 +28,11 @@ import type { TimePeriodFilter, UserAuthSuccess } from '@/types'; type FastSessionAuth = Pick; +type FastSessionTaskSummary = { + taskId: string; + title: string; +}; + export type FastSessionMessage = Pick< FastAgentMessage, | 'id' @@ -120,6 +129,56 @@ export async function findAccessibleFastSession( return session ?? null; } +/** + * Fast conversations predate the unified Session tables. Their delegated tasks + * are linked directly from task runs, rather than through session_tasks. + */ +export async function getFastSessionTasks( + auth: FastSessionAuth, + sessionId: string, +): Promise { + const session = await findAccessibleFastSession(auth, sessionId); + if (!session) return null; + + const [conversation] = await db + .select({ + legacyConversationIds: fastAgentConversations.legacyConversationIds, + }) + .from(fastAgentConversations) + .where(eq(fastAgentConversations.id, session.id)) + .limit(1); + const lookupIds = [ + session.id, + ...(conversation?.legacyConversationIds ?? []), + ]; + const latestRunPerTask = db.$with('latest_fast_session_task_runs').as( + db + .selectDistinctOn([taskRuns.taskId], { + taskId: taskRuns.taskId, + title: tasks.title, + latestRunId: taskRuns.id, + }) + .from(taskRuns) + .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) + .where( + and( + inArray(taskRuns.fastAgentSessionId, lookupIds), + isNull(tasks.deletedAt), + ), + ) + .orderBy(taskRuns.taskId, desc(taskRuns.id)), + ); + + return db + .with(latestRunPerTask) + .select({ + taskId: latestRunPerTask.taskId, + title: latestRunPerTask.title, + }) + .from(latestRunPerTask) + .orderBy(desc(latestRunPerTask.latestRunId)); +} + function sanitizeFastSessionMessageRow< T extends Pick< FastSessionMessage, diff --git a/apps/web/src/lib/server/sessions.test.ts b/apps/web/src/lib/server/sessions.test.ts new file mode 100644 index 000000000..a0e9239ad --- /dev/null +++ b/apps/web/src/lib/server/sessions.test.ts @@ -0,0 +1,180 @@ +import { + db, + fastAgentConversations, + fastAgentMessages, + sessionFactory, + sessionTasks, + taskFactory, + userFactory, +} from '@roomote/db/server'; + +import { + findAccessibleSession, + getSessionById, + getSessionForTask, + getSessions, + getSessionTimeline, + setSessionPinned, + updateSessionMetadata, +} from './sessions'; + +describe('unified Session queries', () => { + it('scopes list and detail reads to owners, participants, and admins', async () => { + const owner = await userFactory.create(); + const stranger = await userFactory.create(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + title: 'Visible Session', + }); + + await expect( + findAccessibleSession({ userId: owner.id, isAdmin: false }, session.id), + ).resolves.toMatchObject({ id: session.id }); + await expect( + findAccessibleSession( + { userId: stranger.id, isAdmin: false }, + session.id, + ), + ).resolves.toBeNull(); + await expect( + findAccessibleSession({ userId: stranger.id, isAdmin: true }, session.id), + ).resolves.toMatchObject({ id: session.id }); + + const list = await getSessions( + { userId: owner.id, isAdmin: false }, + { scope: 'all' }, + ); + expect(list.sessions.map((row) => row.id)).toContain(session.id); + }); + + it('returns task rollups, task resolution, and deterministic timeline events', async () => { + const owner = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: owner.id, + surface: 'web', + workspaceId: owner.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + title: 'Composed Session', + fastConversationId: conversation!.id, + }); + const task = await taskFactory.create({ + initiatorUserId: owner.id, + title: 'Delegated work', + }); + await db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'fast_delegation', + }); + await db.insert(fastAgentMessages).values({ + conversationId: conversation!.id, + eventId: 'message-1', + turnId: 'turn-1', + turnSeq: 0, + ts: 100, + eventType: 'roomote_runtime.user_prompt', + role: 'user', + contentBlocks: [{ type: 'text', text: 'Please delegate this' }], + metadata: { userId: owner.id, visibleInTranscript: true }, + payload: {}, + }); + + const detail = await getSessionById( + { userId: owner.id, isAdmin: false }, + session.id, + ); + expect(detail?.tasks).toEqual([ + expect.objectContaining({ taskId: task.id, title: 'Delegated work' }), + ]); + await expect( + getSessionForTask({ userId: owner.id, isAdmin: false }, task.id), + ).resolves.toEqual({ sessionId: session.id, title: 'Composed Session' }); + const timeline = await getSessionTimeline( + { userId: owner.id, isAdmin: false }, + session.id, + ); + expect(timeline?.events.map((event) => event.id)).toEqual( + expect.arrayContaining([ + 'fast:message-1', + `task:${task.id}:delegated`, + `task:${task.id}:${task.state}`, + ]), + ); + const taskEvent = timeline?.events.find( + (event) => event.type === 'task_delegated', + ); + expect(taskEvent).not.toHaveProperty('task.latestRun'); + expect(taskEvent).not.toHaveProperty('task.artifacts'); + expect(taskEvent).not.toHaveProperty('task.pullRequests'); + }); + + it('excludes soft-deleted tasks from Session detail, timeline, and live status', async () => { + const owner = await userFactory.create(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + cachedStatus: 'blocked', + }); + const task = await taskFactory.create({ + initiatorUserId: owner.id, + state: 'failed', + deletedAt: new Date(), + }); + await db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'direct_launch', + }); + + const detail = await getSessionById( + { userId: owner.id, isAdmin: false }, + session.id, + ); + const timeline = await getSessionTimeline( + { userId: owner.id, isAdmin: false }, + session.id, + ); + + expect(detail?.tasks).toEqual([]); + expect(detail?.status).toBe('ready'); + expect( + timeline?.events.some((event) => event.id.startsWith(`task:${task.id}:`)), + ).toBe(false); + }); + + it('keeps metadata changes owner-only and stores per-user pins', async () => { + const owner = await userFactory.create(); + const stranger = await userFactory.create(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + }); + + await expect( + updateSessionMetadata( + { userId: stranger.id, isAdmin: false }, + session.id, + { title: 'Nope' }, + ), + ).resolves.toBeNull(); + await expect( + updateSessionMetadata({ userId: owner.id, isAdmin: false }, session.id, { + title: 'Renamed', + }), + ).resolves.toMatchObject({ title: 'Renamed' }); + await expect( + setSessionPinned( + { userId: owner.id, isAdmin: false }, + { sessionId: session.id, pinned: true }, + ), + ).resolves.toEqual({ success: true, pinned: true }); + }); +}); diff --git a/apps/web/src/lib/server/sessions.ts b/apps/web/src/lib/server/sessions.ts new file mode 100644 index 000000000..4f3f6c304 --- /dev/null +++ b/apps/web/src/lib/server/sessions.ts @@ -0,0 +1,679 @@ +import { + and, + count, + db, + desc, + deriveSessionStatus, + eq, + exists, + fastAgentMessages, + gte, + ilike, + inArray, + isNull, + llmUsageEvents, + lt, + or, + sessionParticipants, + sessionPins, + sessions, + sessionTasks, + sql, + taskArtifacts, + taskPullRequests, + taskRuns, + tasks, + users, +} from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { getFastSessionById } from './fast-sessions'; + +type SessionAuth = Pick; +export type SessionScope = 'all' | 'tasks' | 'reviews' | 'automations'; + +type SessionListInput = { + scope?: SessionScope; + status?: 'active' | 'needs_input' | 'blocked' | 'ready'; + user?: string | null; + repository?: string | null; + environment?: string | null; + pullRequest?: string | null; + source?: string | null; + model?: string | null; + period?: number | 'all'; + q?: string | null; + before?: string | null; + limit?: number; +}; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +function sessionScope(auth: SessionAuth) { + if (auth.isAdmin) return undefined; + return or( + eq(sessions.ownerUserId, auth.userId), + exists( + db + .select({ one: sql`1` }) + .from(sessionParticipants) + .where( + and( + eq(sessionParticipants.sessionId, sessions.id), + eq(sessionParticipants.userId, auth.userId), + ), + ), + ), + exists( + db + .select({ one: sql`1` }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, sessions.fastConversationId), + sql`${fastAgentMessages.metadata} ->> 'userId' = ${auth.userId}`, + ), + ), + ), + ); +} + +function encodeCursor(row: { activityAt: number; id: string }): string { + return `${row.activityAt}:${row.id}`; +} + +function decodeCursor(cursor?: string | null) { + if (!cursor) return null; + const separator = cursor.indexOf(':'); + const activityAt = Number(cursor.slice(0, separator)); + const id = cursor.slice(separator + 1); + return separator > 0 && Number.isFinite(activityAt) && id + ? { activityAt, id } + : null; +} + +function taskExistsCondition(condition?: ReturnType) { + return exists( + db + .select({ one: sql`1` }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and( + eq(sessionTasks.sessionId, sessions.id), + isNull(tasks.deletedAt), + condition, + ), + ), + ); +} + +function listConditions(auth: SessionAuth, input: SessionListInput) { + const cursor = decodeCursor(input.before); + const scope = input.scope ?? 'all'; + const query = input.q?.trim(); + const period = input.period ?? 'all'; + const pullRequestNumber = Number(input.pullRequest); + + return and( + sessionScope(auth), + eq(sessions.visibility, 'visible'), + isNull(sessions.archivedAt), + input.status ? eq(sessions.cachedStatus, input.status) : undefined, + input.user ? eq(sessions.ownerUserId, input.user) : undefined, + input.source + ? eq(sessions.sourceSurface, input.source as never) + : undefined, + period === 'all' + ? undefined + : gte( + sessions.activityAt, + Math.floor(Date.now() / 1000) - period * 24 * 60 * 60, + ), + cursor + ? or( + lt(sessions.activityAt, cursor.activityAt), + and( + eq(sessions.activityAt, cursor.activityAt), + lt(sessions.id, cursor.id), + ), + ) + : undefined, + scope === 'tasks' ? taskExistsCondition() : undefined, + scope === 'reviews' + ? taskExistsCondition(eq(tasks.workflow, 'pr_review')) + : undefined, + scope === 'automations' ? eq(sessions.ownerKind, 'automation') : undefined, + input.repository + ? taskExistsCondition(eq(tasks.repositoryName, input.repository)) + : undefined, + input.environment + ? exists( + db + .select({ one: sql`1` }) + .from(sessionTasks) + .innerJoin(taskRuns, eq(taskRuns.taskId, sessionTasks.taskId)) + .where( + and( + eq(sessionTasks.sessionId, sessions.id), + sql`${taskRuns.payload} ->> 'environmentId' = ${input.environment}`, + ), + ), + ) + : undefined, + input.model ? taskExistsCondition(eq(tasks.model, input.model)) : undefined, + input.pullRequest && Number.isFinite(pullRequestNumber) + ? exists( + db + .select({ one: sql`1` }) + .from(sessionTasks) + .innerJoin( + taskPullRequests, + eq(taskPullRequests.taskId, sessionTasks.taskId), + ) + .where( + and( + eq(sessionTasks.sessionId, sessions.id), + eq(taskPullRequests.prNumber, pullRequestNumber), + ), + ), + ) + : undefined, + query + ? or( + ilike(sessions.title, `%${query.replaceAll('%', '\\%')}%`), + exists( + db + .select({ one: sql`1` }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and( + eq(sessionTasks.sessionId, sessions.id), + or( + ilike(tasks.title, `%${query.replaceAll('%', '\\%')}%`), + ilike( + tasks.repositoryName, + `%${query.replaceAll('%', '\\%')}%`, + ), + ), + ), + ), + ), + ) + : undefined, + ); +} + +const baseSelection = { + id: sessions.id, + title: sessions.title, + ownerKind: sessions.ownerKind, + ownerUserId: sessions.ownerUserId, + ownerAutomation: sessions.ownerAutomation, + ownerName: users.name, + ownerEmail: users.email, + ownerImageUrl: users.imageUrl, + sourceSurface: sessions.sourceSurface, + sourceTrigger: sessions.sourceTrigger, + fastConversationId: sessions.fastConversationId, + visibility: sessions.visibility, + activityAt: sessions.activityAt, + cachedStatus: sessions.cachedStatus, + archivedAt: sessions.archivedAt, + createdAt: sessions.createdAt, + updatedAt: sessions.updatedAt, +}; + +async function hydrateSessionRows( + auth: SessionAuth, + rows: Array< + typeof sessions.$inferSelect & { + ownerName: string | null; + ownerEmail: string | null; + ownerImageUrl: string | null; + } + >, +) { + if (rows.length === 0) return []; + const ids = rows.map((row) => row.id); + const [ + linkedTasks, + participants, + usage, + legacyTaskUsage, + legacyFastUsage, + externalFastActivity, + pins, + ] = await Promise.all([ + db + .select({ + sessionId: sessionTasks.sessionId, + taskId: tasks.id, + title: tasks.title, + workflow: tasks.workflow, + state: tasks.state, + repositoryName: tasks.repositoryName, + model: tasks.model, + activityAt: tasks.activityAt, + }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and(inArray(sessionTasks.sessionId, ids), isNull(tasks.deletedAt)), + ), + db + .select({ + sessionId: sessionParticipants.sessionId, + userId: sessionParticipants.userId, + role: sessionParticipants.role, + lastReadEventAt: sessionParticipants.lastReadEventAt, + lastReadEventId: sessionParticipants.lastReadEventId, + }) + .from(sessionParticipants) + .where(inArray(sessionParticipants.sessionId, ids)), + db + .select({ + sessionId: llmUsageEvents.sessionId, + costMicroUsd: sql`coalesce(sum(${llmUsageEvents.costMicroUsd}), 0)::bigint`, + }) + .from(llmUsageEvents) + .where(inArray(llmUsageEvents.sessionId, ids)) + .groupBy(llmUsageEvents.sessionId), + db + .select({ + sessionId: sessionTasks.sessionId, + costMicroUsd: sql`coalesce(sum(${llmUsageEvents.costMicroUsd}), 0)::bigint`, + }) + .from(sessionTasks) + .innerJoin(llmUsageEvents, eq(llmUsageEvents.taskId, sessionTasks.taskId)) + .where( + and( + inArray(sessionTasks.sessionId, ids), + isNull(llmUsageEvents.sessionId), + ), + ) + .groupBy(sessionTasks.sessionId), + db + .select({ + sessionId: sessions.id, + costMicroUsd: sql`( + select coalesce(sum(legacy_usage.cost_micro_usd), 0)::bigint + from task_inference_usage_events legacy_usage + where legacy_usage.session_id is null + and legacy_usage.harness_session_id in ( + select distinct ${fastAgentMessages.nativeSessionId} + from ${fastAgentMessages} + where ${fastAgentMessages.conversationId} = ${sessions.fastConversationId} + and ${fastAgentMessages.nativeSessionId} is not null + ) + )`, + }) + .from(sessions) + .where(inArray(sessions.id, ids)), + db + .select({ + sessionId: sessions.id, + eventAt: sql`coalesce(max(${fastAgentMessages.ts}), 0)::bigint`, + }) + .from(sessions) + .innerJoin( + fastAgentMessages, + eq(fastAgentMessages.conversationId, sessions.fastConversationId), + ) + .where( + and( + inArray(sessions.id, ids), + or( + sql`${fastAgentMessages.metadata} ->> 'userId' IS NULL`, + sql`${fastAgentMessages.metadata} ->> 'userId' <> ${auth.userId}`, + ), + ), + ) + .groupBy(sessions.id), + db + .select({ sessionId: sessionPins.sessionId }) + .from(sessionPins) + .where( + and( + eq(sessionPins.userId, auth.userId), + inArray(sessionPins.sessionId, ids), + ), + ), + ]); + + const pinned = new Set(pins.map((pin) => pin.sessionId)); + return rows.map((row) => { + const tasksForSession = linkedTasks.filter( + (task) => task.sessionId === row.id, + ); + const sessionParticipantsRows = participants.filter( + (participant) => participant.sessionId === row.id, + ); + const cursor = sessionParticipantsRows.find( + (participant) => participant.userId === auth.userId, + ); + const latestTaskEventAt = tasksForSession.reduce( + (latest, task) => Math.max(latest, task.activityAt * 1000), + 0, + ); + const latestExternalEventAt = Math.max( + latestTaskEventAt, + Number( + externalFastActivity.find((event) => event.sessionId === row.id) + ?.eventAt ?? 0, + ), + ); + return { + ...row, + tasks: tasksForSession, + executionCount: tasksForSession.length, + participants: sessionParticipantsRows, + inferenceCostMicroUsd: + Number( + usage.find((event) => event.sessionId === row.id)?.costMicroUsd ?? 0, + ) + + Number( + legacyTaskUsage.find((event) => event.sessionId === row.id) + ?.costMicroUsd ?? 0, + ) + + Number( + legacyFastUsage.find((event) => event.sessionId === row.id) + ?.costMicroUsd ?? 0, + ), + unread: latestExternalEventAt > Number(cursor?.lastReadEventAt ?? 0), + pinned: pinned.has(row.id), + }; + }); +} + +export async function getSessions(auth: SessionAuth, input: SessionListInput) { + const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const rows = await db + .select(baseSelection) + .from(sessions) + .leftJoin(users, eq(users.id, sessions.ownerUserId)) + .where(listConditions(auth, input)) + .orderBy(desc(sessions.activityAt), desc(sessions.id)) + .limit(limit + 1); + const page = rows.slice(0, limit); + const last = page.at(-1); + return { + sessions: await hydrateSessionRows(auth, page), + nextCursor: rows.length > limit && last ? encodeCursor(last) : null, + }; +} + +export async function findAccessibleSession( + auth: SessionAuth, + sessionId: string, +) { + const [session] = await db + .select(baseSelection) + .from(sessions) + .leftJoin(users, eq(users.id, sessions.ownerUserId)) + .where(and(eq(sessions.id, sessionId), sessionScope(auth))) + .limit(1); + return session ?? null; +} + +async function findAccessibleSessionByFastConversationId( + auth: SessionAuth, + fastConversationId: string, +) { + const [session] = await db + .select(baseSelection) + .from(sessions) + .leftJoin(users, eq(users.id, sessions.ownerUserId)) + .where( + and( + eq(sessions.fastConversationId, fastConversationId), + sessionScope(auth), + ), + ) + .limit(1); + return session ?? null; +} + +async function getSessionTasks(sessionId: string) { + const linked = await db + .select({ + sessionId: sessionTasks.sessionId, + taskId: tasks.id, + attachedAt: sessionTasks.attachedAt, + origin: sessionTasks.origin, + title: tasks.title, + workflow: tasks.workflow, + state: tasks.state, + goalStatus: tasks.goalStatus, + repositoryName: tasks.repositoryName, + model: tasks.model, + activityAt: tasks.activityAt, + }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where(and(eq(sessionTasks.sessionId, sessionId), isNull(tasks.deletedAt))) + .orderBy(sessionTasks.attachedAt); + + return Promise.all( + linked.map(async (task) => { + const [latestRun, artifacts, pullRequests, usage] = await Promise.all([ + db.query.taskRuns.findFirst({ + where: eq(taskRuns.taskId, task.taskId), + orderBy: desc(taskRuns.id), + columns: { + id: true, + status: true, + taskPhase: true, + error: true, + result: true, + }, + }), + db + .select({ + id: taskArtifacts.id, + path: taskArtifacts.path, + artifactType: taskArtifacts.artifactType, + contentType: taskArtifacts.contentType, + size: taskArtifacts.size, + }) + .from(taskArtifacts) + .where(eq(taskArtifacts.taskId, task.taskId)) + .orderBy(desc(taskArtifacts.createdAt)), + db + .select({ + id: taskPullRequests.id, + url: taskPullRequests.prUrl, + number: taskPullRequests.prNumber, + title: taskPullRequests.prTitle, + repository: taskPullRequests.repository, + status: taskPullRequests.status, + }) + .from(taskPullRequests) + .where(eq(taskPullRequests.taskId, task.taskId)), + db + .select({ + costMicroUsd: sql`coalesce(sum(${llmUsageEvents.costMicroUsd}), 0)::bigint`, + }) + .from(llmUsageEvents) + .where(eq(llmUsageEvents.taskId, task.taskId)), + ]); + const result = latestRun?.result; + const latestOutput = + result && typeof result === 'object' + ? String( + (result as Record).summary ?? + (result as Record).message ?? + '', + ) + .trim() + .slice(0, 240) || null + : null; + return { + ...task, + latestRun: latestRun ?? null, + latestOutput, + inferenceCostMicroUsd: Number(usage[0]?.costMicroUsd ?? 0), + artifacts, + pullRequests, + }; + }), + ); +} + +export async function getSessionById(auth: SessionAuth, sessionId: string) { + const session = + (await findAccessibleSession(auth, sessionId)) ?? + (await findAccessibleSessionByFastConversationId(auth, sessionId)); + if (!session) return null; + const [hydrated] = await hydrateSessionRows(auth, [session]); + const sessionTaskDetails = await getSessionTasks(session.id); + const liveStatus = deriveSessionStatus({ + conversationResponding: + Boolean(session.fastConversationId) && session.cachedStatus === 'active', + tasks: sessionTaskDetails.map((task) => ({ + state: task.state, + taskPhase: task.latestRun?.taskPhase ?? null, + goalStatus: task.goalStatus, + })), + }); + return { ...hydrated!, tasks: sessionTaskDetails, status: liveStatus }; +} + +export async function getSessionTimeline( + auth: SessionAuth, + sessionId: string, + since = 0, +) { + const session = await findAccessibleSession(auth, sessionId); + if (!session) return null; + const taskRows = await getSessionTasks(sessionId); + const fast = session.fastConversationId + ? await getFastSessionById(auth, session.fastConversationId) + : null; + const timelineTasks = taskRows.map((task) => ({ + taskId: task.taskId, + title: task.title, + workflow: task.workflow, + state: task.state, + goalStatus: task.goalStatus, + repositoryName: task.repositoryName, + activityAt: task.activityAt, + attachedAt: task.attachedAt, + origin: task.origin, + })); + const events = [ + ...(fast?.messages ?? []).map((message) => ({ + id: `fast:${message.eventId}`, + at: message.ts, + type: 'message' as const, + own: message.metadata?.userId === auth.userId, + message, + })), + ...timelineTasks.flatMap((task) => [ + { + id: `task:${task.taskId}:delegated`, + at: task.attachedAt.getTime(), + type: 'task_delegated' as const, + own: false, + task, + }, + { + id: `task:${task.taskId}:${task.state}`, + at: task.activityAt * 1000, + type: 'task_state' as const, + own: false, + task, + }, + ]), + ] + .filter((event) => event.at > since) + .sort( + (left, right) => left.at - right.at || left.id.localeCompare(right.id), + ); + return { events, cursor: events.at(-1)?.at ?? since }; +} + +export async function getSessionForTask(auth: SessionAuth, taskId: string) { + const [row] = await db + .select({ sessionId: sessions.id, title: sessions.title }) + .from(sessionTasks) + .innerJoin(sessions, eq(sessions.id, sessionTasks.sessionId)) + .where(and(eq(sessionTasks.taskId, taskId), sessionScope(auth))) + .limit(1); + return row ?? null; +} + +export async function updateSessionMetadata( + auth: SessionAuth, + sessionId: string, + changes: { title?: string; archivedAt?: Date | null }, +) { + const [updated] = await db + .update(sessions) + .set({ ...changes, updatedAt: new Date() }) + .where( + and( + eq(sessions.id, sessionId), + auth.isAdmin ? undefined : eq(sessions.ownerUserId, auth.userId), + ), + ) + .returning(); + return updated ?? null; +} + +export async function listSessionPins(auth: SessionAuth) { + return db + .select({ + sessionId: sessionPins.sessionId, + updatedAt: sessionPins.updatedAt, + }) + .from(sessionPins) + .innerJoin(sessions, eq(sessions.id, sessionPins.sessionId)) + .where(and(eq(sessionPins.userId, auth.userId), sessionScope(auth))) + .orderBy(desc(sessionPins.updatedAt)); +} + +export async function setSessionPinned( + auth: SessionAuth, + input: { sessionId: string; pinned: boolean }, +) { + if (!input.pinned) { + await db + .delete(sessionPins) + .where( + and( + eq(sessionPins.sessionId, input.sessionId), + eq(sessionPins.userId, auth.userId), + ), + ); + return { success: true as const, pinned: false }; + } + if (!(await findAccessibleSession(auth, input.sessionId))) { + return { success: false as const, error: 'session_not_found' as const }; + } + const [existing] = await db + .select({ id: sessionPins.id }) + .from(sessionPins) + .where( + and( + eq(sessionPins.sessionId, input.sessionId), + eq(sessionPins.userId, auth.userId), + ), + ); + if (existing) return { success: true as const, pinned: true }; + const [total] = await db + .select({ value: count() }) + .from(sessionPins) + .where(eq(sessionPins.userId, auth.userId)); + if ((total?.value ?? 0) >= 5) { + return { success: false as const, error: 'pin_limit_reached' as const }; + } + await db.insert(sessionPins).values({ + sessionId: input.sessionId, + userId: auth.userId, + }); + return { success: true as const, pinned: true }; +} diff --git a/apps/web/src/lib/telemetry/normalize-path.ts b/apps/web/src/lib/telemetry/normalize-path.ts index dde94dd27..b95c5f251 100644 --- a/apps/web/src/lib/telemetry/normalize-path.ts +++ b/apps/web/src/lib/telemetry/normalize-path.ts @@ -69,6 +69,10 @@ function normalizeTaskPath(pathname: string): string | null { return ['/task/[taskId]', ...restSegments].join('/'); } +function normalizeSessionPath(pathname: string): string | null { + return /^\/sessions\/[^/]+$/.test(pathname) ? '/sessions/[sessionId]' : null; +} + /** @public */ export interface NormalizedPath { path: string; @@ -81,7 +85,8 @@ export function normalizePath( ): NormalizedPath { const pathname = rawPathname.split('?')[0] ?? '/'; - let path: string | null = normalizeTaskPath(pathname); + let path: string | null = + normalizeTaskPath(pathname) ?? normalizeSessionPath(pathname); if (path === null) { for (const matcher of DYNAMIC_ROUTE_MATCHERS) { diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index dc7e9280e..7461a75ea 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -13,7 +13,12 @@ import { resolveUserMcpServerConfigs, type FastAgentSurfaceReplyDelivery, } from '@roomote/sdk/server'; -import { db, eq, fastAgentConversations } from '@roomote/db/server'; +import { + db, + eq, + fastAgentConversations, + getSessionForFastConversation, +} from '@roomote/db/server'; import { formatErrorForLog, getUserDisplayName, @@ -21,7 +26,10 @@ import { } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; -import { findAccessibleFastSession } from '@/lib/server/fast-sessions'; +import { + findAccessibleFastSession, + getFastSessionTasks, +} from '@/lib/server/fast-sessions'; /** * Persist the session's model settings when the caller sent an explicit @@ -157,7 +165,7 @@ export async function startFastSessionCommand( model?: string | null; reasoningEffort?: ReasoningEffort | null; }, -): Promise<{ sessionId: string }> { +): Promise<{ sessionId: string; fastConversationId?: string }> { const conversation: WebFastAgentConversation = { surface: 'web', workspaceId: auth.userId, @@ -191,7 +199,20 @@ export async function startFastSessionCommand( reasoningEffort: settings.reasoningEffort, }); - return { sessionId: session.id }; + const unifiedSession = auth.featureFlags.sessions_ui + ? await getSessionForFastConversation(db, session.id) + : null; + return { + sessionId: unifiedSession?.id ?? session.id, + fastConversationId: session.id, + }; +} + +export async function getFastSessionTasksCommand( + auth: UserAuthSuccess, + sessionId: string, +) { + return getFastSessionTasks(auth, sessionId); } export async function replyToFastSessionCommand( diff --git a/apps/web/src/trpc/commands/feature-flags/index.test.ts b/apps/web/src/trpc/commands/feature-flags/index.test.ts index a2f9fa882..60aaa9b80 100644 --- a/apps/web/src/trpc/commands/feature-flags/index.test.ts +++ b/apps/web/src/trpc/commands/feature-flags/index.test.ts @@ -52,11 +52,27 @@ function buildAuth(isAdmin: boolean): UserAuthSuccess { describe('feature-flags commands', () => { beforeEach(() => vi.clearAllMocks()); - it('returns no experimental flags', async () => { + it('returns the default-off Sessions rollout flags', async () => { await expect(getExperimentalFlagsCommand(buildAuth(true))).resolves.toEqual( - [], + [ + expect.objectContaining({ + id: 'sessions_data', + value: false, + explicitlySet: false, + }), + expect.objectContaining({ + id: 'sessions_ui', + value: false, + explicitlySet: false, + }), + expect.objectContaining({ + id: 'sessions_comms', + value: false, + explicitlySet: false, + }), + ], ); - expect(mockFindFirst).not.toHaveBeenCalled(); + expect(mockFindFirst).toHaveBeenCalledOnce(); }); it('rejects stale flags before metadata lookup or a database write', async () => { diff --git a/apps/web/src/trpc/commands/sessions/index.test.ts b/apps/web/src/trpc/commands/sessions/index.test.ts new file mode 100644 index 000000000..a325348a1 --- /dev/null +++ b/apps/web/src/trpc/commands/sessions/index.test.ts @@ -0,0 +1,63 @@ +import type { UserAuthSuccess } from '@/types'; + +const { getSessionByIdMock, resolveTaskAccessMock } = vi.hoisted(() => ({ + getSessionByIdMock: vi.fn(), + resolveTaskAccessMock: vi.fn(), +})); + +vi.mock('@/lib/server/sessions', () => ({ + findAccessibleSession: vi.fn(), + getSessionById: getSessionByIdMock, + getSessionForTask: vi.fn(), + getSessions: vi.fn(), + getSessionTimeline: vi.fn(), + listSessionPins: vi.fn(), + setSessionPinned: vi.fn(), + updateSessionMetadata: vi.fn(), +})); +vi.mock('../tasks/by-id', () => ({ + resolveTaskByIdAccessCommand: resolveTaskAccessMock, +})); +vi.mock('@roomote/db/server', () => ({ + advanceSessionReadCursor: vi.fn(), + db: {}, +})); +vi.mock('@roomote/telemetry/server', () => ({ captureEvent: vi.fn() })); + +import { getSessionByIdCommand } from './index'; + +describe('getSessionByIdCommand', () => { + it('redacts execution details when Session access exceeds task access', async () => { + getSessionByIdMock.mockResolvedValue({ + id: 'session-1', + tasks: [ + { + taskId: 'task-1', + title: 'Private execution', + latestRun: { id: 1, error: 'private error', result: {} }, + latestOutput: 'private output', + inferenceCostMicroUsd: 123, + artifacts: [{ id: 'artifact-1', path: 'private.txt' }], + pullRequests: [{ id: 'pr-1', url: 'https://example.com/private' }], + }, + ], + }); + resolveTaskAccessMock.mockResolvedValue({ kind: 'not-found' }); + + const result = await getSessionByIdCommand( + { userId: 'user-1', isAdmin: false } as UserAuthSuccess, + 'session-1', + ); + + expect(result?.tasks[0]).toEqual( + expect.objectContaining({ + canAccessDetails: false, + latestRun: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + artifacts: [], + pullRequests: [], + }), + ); + }); +}); diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts new file mode 100644 index 000000000..d2e8ba13e --- /dev/null +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import { advanceSessionReadCursor, db } from '@roomote/db/server'; +import { captureEvent } from '@roomote/telemetry/server'; + +import type { UserAuthSuccess } from '@/types'; +import { + findAccessibleSession, + getSessionById, + getSessionForTask, + getSessions, + getSessionTimeline, + listSessionPins, + setSessionPinned, + updateSessionMetadata, +} from '@/lib/server/sessions'; +import { resolveTaskByIdAccessCommand } from '../tasks/by-id'; + +export const sessionIdInputSchema = z.object({ sessionId: z.string().uuid() }); +export const sessionsListInputSchema = z.object({ + scope: z.enum(['all', 'tasks', 'reviews', 'automations']).optional(), + status: z.enum(['active', 'needs_input', 'blocked', 'ready']).optional(), + user: z.string().nullish(), + repository: z.string().nullish(), + environment: z.string().nullish(), + pullRequest: z.string().nullish(), + source: z.string().nullish(), + model: z.string().nullish(), + period: z.union([z.literal('all'), z.number().int().positive()]).optional(), + q: z.string().max(200).nullish(), + before: z.string().nullish(), + limit: z.number().int().min(1).max(200).optional(), +}); + +export async function markSessionReadCommand( + auth: UserAuthSuccess, + input: { sessionId: string; throughEventAt: number; throughEventId: string }, +) { + if (!(await findAccessibleSession(auth, input.sessionId))) return null; + return advanceSessionReadCursor(db, { + sessionId: input.sessionId, + userId: auth.userId, + eventAt: input.throughEventAt, + eventId: input.throughEventId, + }); +} + +export async function getSessionByIdCommand( + auth: UserAuthSuccess, + sessionId: string, +) { + const session = await getSessionById(auth, sessionId); + if (!session) return null; + + const taskAccess = await Promise.all( + session.tasks.map((task) => + resolveTaskByIdAccessCommand(auth, { + taskId: task.taskId, + includeArtifacts: true, + }), + ), + ); + + return { + ...session, + tasks: session.tasks.map((task, index) => + taskAccess[index]?.kind === 'resolved' + ? { ...task, canAccessDetails: true as const } + : { + ...task, + canAccessDetails: false as const, + latestRun: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + artifacts: [], + pullRequests: [], + }, + ), + }; +} + +export async function archiveSessionCommand( + auth: UserAuthSuccess, + sessionId: string, +) { + const archived = await updateSessionMetadata(auth, sessionId, { + archivedAt: new Date(), + }); + if (archived) { + void captureEvent('session_archived', { + userId: auth.userId, + properties: { surface: 'web', outcome: 'archived' }, + }); + } + return archived; +} + +export { + getSessionForTask, + getSessions, + getSessionTimeline, + listSessionPins, + setSessionPinned, + updateSessionMetadata, +}; diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts index a05deb488..add345283 100644 --- a/apps/web/src/trpc/commands/task-runs/index.ts +++ b/apps/web/src/trpc/commands/task-runs/index.ts @@ -28,6 +28,7 @@ import { inArray, markTaskStartParallelCountEndedAt, prepareTaskGoalActivation, + sessionTasks, slackInstallations, taskRuns, tasks, @@ -45,7 +46,7 @@ import { sendSandboxPromptCommand } from '../sandbox-session'; import { resolveTaskByIdAccessCommand } from '../tasks/by-id'; export type CreateTaskRunResult = - | { success: true; id: number; taskId: string } + | { success: true; id: number; taskId: string; sessionId?: string } | { success: false; error: string }; export async function startTaskGoalCommand( @@ -464,6 +465,12 @@ export async function createStandardTaskRunCommand( surface: 'web', trigger: 'manual', }); + const linkedSession = auth.featureFlags.sessions_ui + ? await db.query.sessionTasks.findFirst({ + where: eq(sessionTasks.taskId, launchResult.taskId), + columns: { sessionId: true }, + }) + : null; try { await notifySourceTaskArtifactBuild({ @@ -486,6 +493,7 @@ export async function createStandardTaskRunCommand( success: true, id: launchResult.id, taskId: launchResult.taskId, + sessionId: linkedSession?.sessionId, }; } catch (error) { console.error(error); diff --git a/apps/web/src/trpc/commands/tasks/__tests__/delete.test.ts b/apps/web/src/trpc/commands/tasks/__tests__/delete.test.ts index 8b53ef589..8fd780327 100644 --- a/apps/web/src/trpc/commands/tasks/__tests__/delete.test.ts +++ b/apps/web/src/trpc/commands/tasks/__tests__/delete.test.ts @@ -3,6 +3,8 @@ import type { UserAuthSuccess } from '@/types'; const { mockDeleteArtifactsBatch, mockMarkParallelCounts, + mockGetSessionForTask, + mockTouchSessionActivity, tasksTable, taskArtifactsTable, deleteCalls, @@ -10,6 +12,8 @@ const { } = vi.hoisted(() => ({ mockDeleteArtifactsBatch: vi.fn(), mockMarkParallelCounts: vi.fn(), + mockGetSessionForTask: vi.fn(), + mockTouchSessionActivity: vi.fn(), tasksTable: { id: 'tasks.id', deletedAt: 'tasks.deletedAt' }, taskArtifactsTable: { id: 'taskArtifacts.id', @@ -64,6 +68,8 @@ vi.mock('@roomote/db/server', () => ({ tasks: tasksTable, taskArtifacts: taskArtifactsTable, markTaskStartParallelCountsEndedAtForTaskIds: mockMarkParallelCounts, + getSessionForTask: mockGetSessionForTask, + touchSessionActivity: mockTouchSessionActivity, and: (...conditions: unknown[]) => ({ and: conditions }), inArray: (column: unknown, values: unknown) => ({ inArray: [column, values], @@ -89,6 +95,10 @@ describe('deleteTasksCommand', () => { vi.clearAllMocks(); deleteCalls.length = 0; mockDeleteArtifactsBatch.mockResolvedValue({ deleted: 1, errors: 0 }); + mockGetSessionForTask.mockResolvedValue({ + id: 'session-1', + activityAt: 100, + }); }); it('deletes taskArtifacts rows inside the soft-delete transaction', async () => { @@ -114,5 +124,11 @@ describe('deleteTasksCommand', () => { (call as { table: unknown }).table === taskArtifactsTable, ); expect(artifactDelete).toBeDefined(); + expect(mockGetSessionForTask).toHaveBeenCalledWith(fakeTx, 'task-1'); + expect(mockTouchSessionActivity).toHaveBeenCalledWith( + fakeTx, + 'session-1', + 100, + ); }); }); diff --git a/apps/web/src/trpc/commands/tasks/delete.ts b/apps/web/src/trpc/commands/tasks/delete.ts index 3e95b621d..3c41429d9 100644 --- a/apps/web/src/trpc/commands/tasks/delete.ts +++ b/apps/web/src/trpc/commands/tasks/delete.ts @@ -2,6 +2,8 @@ import { db, tasks, markTaskStartParallelCountsEndedAtForTaskIds, + getSessionForTask, + touchSessionActivity, taskArtifacts, and, inArray, @@ -95,6 +97,18 @@ export async function deleteTasksCommand( .where(and(...whereConditions)) .returning({ id: tasks.id }); + const affectedSessions = new Map< + string, + NonNullable>> + >(); + for (const deletedTask of deletedTasksResult) { + const session = await getSessionForTask(tx, deletedTask.id); + if (session) affectedSessions.set(session.id, session); + } + for (const session of affectedSessions.values()) { + await touchSessionActivity(tx, session.id, session.activityAt); + } + return { deletedTasks: deletedTasksResult, artifactsDeleted: s3Result.deleted, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 822f503b9..23825e807 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -32,6 +32,7 @@ import { } from '@roomote/types'; import { + getFastSessionTasksCommand, replyToFastSessionCommand, startFastSessionCommand, } from '../commands/fast-sessions'; @@ -39,6 +40,19 @@ import { replyToFastSessionInputSchema, startFastSessionInputSchema, } from '../commands/fast-sessions/input'; +import { + getSessionByIdCommand, + getSessionForTask, + getSessions, + getSessionTimeline, + archiveSessionCommand, + listSessionPins, + markSessionReadCommand, + sessionIdInputSchema, + sessionsListInputSchema, + setSessionPinned, + updateSessionMetadata, +} from '../commands/sessions'; import { analyticsChartInputSchema, analyticsDetailsInputSchema, @@ -2809,6 +2823,77 @@ export const appRouter = createRouter({ .mutation(({ ctx: { auth }, input }) => replyToFastSessionCommand(auth, input), ), + tasks: protectedProcedure + .input(z.object({ sessionId: z.string().uuid() })) + .query(({ ctx: { auth }, input }) => + getFastSessionTasksCommand(auth, input.sessionId), + ), + }), + + sessions: createRouter({ + list: protectedProcedure + .input(sessionsListInputSchema) + .query(({ ctx: { auth }, input }) => getSessions(auth, input)), + byId: protectedProcedure + .input(sessionIdInputSchema) + .query(({ ctx: { auth }, input }) => + getSessionByIdCommand(auth, input.sessionId), + ), + timeline: protectedProcedure + .input(sessionIdInputSchema.extend({ since: z.number().optional() })) + .query(({ ctx: { auth }, input }) => + getSessionTimeline(auth, input.sessionId, input.since), + ), + forTask: protectedProcedure + .input(z.object({ taskId: z.string().min(1) })) + .query(({ ctx: { auth }, input }) => + getSessionForTask(auth, input.taskId), + ), + markRead: protectedProcedure + .input( + sessionIdInputSchema.extend({ + throughEventAt: z.number().nonnegative(), + throughEventId: z.string().min(1), + }), + ) + .mutation(({ ctx: { auth }, input }) => + markSessionReadCommand(auth, input), + ), + rename: protectedProcedure + .input( + sessionIdInputSchema.extend({ + title: z.string().trim().min(1).max(500), + }), + ) + .mutation(({ ctx: { auth }, input }) => + updateSessionMetadata(auth, input.sessionId, { title: input.title }), + ), + archive: protectedProcedure + .input(sessionIdInputSchema) + .mutation(({ ctx: { auth }, input }) => + archiveSessionCommand(auth, input.sessionId), + ), + unarchive: protectedProcedure + .input(sessionIdInputSchema) + .mutation(({ ctx: { auth }, input }) => + updateSessionMetadata(auth, input.sessionId, { archivedAt: null }), + ), + pins: protectedProcedure.query(({ ctx: { auth } }) => + listSessionPins(auth), + ), + setPinned: protectedProcedure + .input(sessionIdInputSchema.extend({ pinned: z.boolean() })) + .mutation(({ ctx: { auth }, input }) => setSessionPinned(auth, input)), + search: protectedProcedure + .input( + z.object({ + query: z.string().max(200), + limit: z.number().int().min(1).max(50).optional(), + }), + ) + .query(({ ctx: { auth }, input }) => + getSessions(auth, { q: input.query, limit: input.limit ?? 20 }), + ), }), agentBehavior: createRouter({ diff --git a/apps/web/src/types/analytics.ts b/apps/web/src/types/analytics.ts index 50f8406e1..74a1a5b2d 100644 --- a/apps/web/src/types/analytics.ts +++ b/apps/web/src/types/analytics.ts @@ -2,7 +2,12 @@ import { z } from 'zod'; import { timePeriodFilterSchema, type TimePeriodFilter } from './time-period'; -export const analyticsObjects = ['tasks', 'pullRequests', 'costs'] as const; +export const analyticsObjects = [ + 'sessions', + 'tasks', + 'pullRequests', + 'costs', +] as const; export const analyticsObjectSchema = z.enum(analyticsObjects); export type AnalyticsObject = z.infer; @@ -21,6 +26,8 @@ const analyticsDimensions = [ 'taskType', 'provider', 'model', + 'ownerKind', + 'hasExecution', ] as const; export const analyticsDimensionSchema = z.enum(analyticsDimensions); export type AnalyticsDimension = z.infer; @@ -42,6 +49,8 @@ export const analyticsFiltersSchema = z taskType: analyticsFilterValueSchema, provider: analyticsFilterValueSchema, model: analyticsFilterValueSchema, + ownerKind: analyticsFilterValueSchema, + hasExecution: analyticsFilterValueSchema, }) .partial(); export type AnalyticsFilters = z.infer; @@ -202,6 +211,27 @@ export type PullRequestAnalyticsOverviewResponse = { }; export const ANALYTICS_OBJECT_CONFIG = { + sessions: { + label: 'Sessions', + axisLabel: 'Sessions', + filterDimensions: [ + 'user', + 'status', + 'source', + 'ownerKind', + 'hasExecution', + ] as AnalyticsDimension[], + viewByDimensions: [ + 'user', + 'status', + 'source', + 'ownerKind', + 'hasExecution', + ] as AnalyticsDimension[], + defaultViewBy: 'status' as AnalyticsDimension, + supportedMetrics: ['tasks'] as readonly AnalyticsMetric[], + defaultMetric: 'tasks' as AnalyticsMetric, + }, tasks: { label: 'Tasks', axisLabel: 'Tasks', @@ -286,6 +316,8 @@ export const ANALYTICS_DIMENSION_LABELS: Record = { taskType: 'Task Type', provider: 'Provider', model: 'Model', + ownerKind: 'Owner kind', + hasExecution: 'Has execution', }; export const ANALYTICS_METRIC_LABELS: Record = { diff --git a/packages/cloud-agents/package.json b/packages/cloud-agents/package.json index 5bdcd044c..c95b22a87 100644 --- a/packages/cloud-agents/package.json +++ b/packages/cloud-agents/package.json @@ -72,6 +72,7 @@ "@roomote/communication": "workspace:^", "@roomote/db": "workspace:^", "@roomote/env": "workspace:^", + "@roomote/feature-flags": "workspace:^", "@roomote/github": "workspace:^", "@roomote/gitea": "workspace:^", "@roomote/gitlab": "workspace:^", diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index cce56bc67..b2b0a0ba1 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -4,6 +4,7 @@ // stamping, resume semantics, enqueue-time PR linkage, and pr_review queue // scope dedup. import Redis from 'ioredis-mock'; +import { invalidateDeploymentFeatureFlagCache } from '@roomote/feature-flags/server'; const { mockGenerateLlmTaskTitle } = vi.hoisted(() => ({ mockGenerateLlmTaskTitle: vi.fn().mockResolvedValue('Generated title'), @@ -39,6 +40,7 @@ import { environments, environmentRepositoryMappings, repositories, + sessionTasks, userFactory, environmentFactory, repositoryFactory, @@ -909,6 +911,59 @@ describe('enqueueTask initiator stamping', () => { }); }); +describe('enqueueTask Session linkage', () => { + beforeEach(async () => { + await db + .insert(deploymentSettings) + .values({ id: 'default', metadata: { sessions_data: true } }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { metadata: { sessions_data: true } }, + }); + invalidateDeploymentFeatureFlagCache(); + }); + + afterEach(async () => { + await db + .update(deploymentSettings) + .set({ metadata: {} }) + .where(eq(deploymentSettings.id, 'default')); + invalidateDeploymentFeatureFlagCache(); + }); + + it('creates exactly one Session link for a visible fresh task', async () => { + const userId = await createUser(); + const run = await launchFresh({ + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + const links = await db + .select() + .from(sessionTasks) + .where(eq(sessionTasks.taskId, run.taskId)); + expect(links).toHaveLength(1); + expect(links[0]?.origin).toBe('direct_launch'); + }); + + it('does not create Session links for hidden tasks', async () => { + const userId = await createUser(); + const run = await launchFresh({ + initiator: { kind: 'user', userId }, + workflow: 'scan', + surface: 'system', + trigger: 'schedule', + visibility: 'hidden', + }); + + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, run.taskId)), + ).resolves.toEqual([]); + }); +}); + describe('enqueueTask snapshot resume', () => { it('atomically rejects concurrent resumes from the same source run', async () => { const userId = await createUser(); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index 7a9789eea..b3c01baea 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -78,6 +78,9 @@ vi.mock('@roomote/db/server', () => ({ appendFastAgentMemory: mocks.appendMemory, isBrainProviderConfigured: mocks.isBrainProviderConfigured, db: {}, + getSessionForFastConversation: vi.fn().mockResolvedValue(null), + getSessionForTask: vi.fn().mockResolvedValue(null), + touchSessionActivity: vi.fn().mockResolvedValue(undefined), })); vi.mock('../../non-task-provider-usage', () => ({ diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts index ee400820e..8325ebb7a 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts @@ -13,7 +13,10 @@ vi.mock('../../task-url', () => ({ import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types'; -import { createFastAgentSlackTaskLauncher } from '../fast-agent-task-launcher'; +import { + createFastAgentSlackTaskLauncher, + createFastAgentWebTaskLauncher, +} from '../fast-agent-task-launcher'; describe('createFastAgentSlackTaskLauncher', () => { beforeEach(() => { @@ -348,3 +351,44 @@ describe('createFastAgentSlackTaskLauncher', () => { expect(queued).toBe(false); }); }); + +describe('createFastAgentWebTaskLauncher', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.enqueueTask.mockImplementation( + async ( + _input: unknown, + options: { + beforeEnqueue: (taskRun: { taskId: string }) => Promise; + }, + ) => { + await options.beforeEnqueue({ taskId: 'task-1' }); + return { taskId: 'task-1' }; + }, + ); + }); + + it('keeps the kickoff free of a duplicate task link', async () => { + const postKickoff = vi.fn(); + + await createFastAgentWebTaskLauncher({ + userId: 'user-1', + conversation: { + surface: 'web', + workspaceId: 'workspace-1', + conversationId: 'conversation-1', + }, + })({ + prompt: 'Fix checkout', + environmentId: null, + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff, + }); + + expect(postKickoff).toHaveBeenCalledWith({ + taskId: 'task-1', + taskUrl: 'https://roomote.example/task/task-1', + taskLinkRendered: true, + }); + }); +}); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts index d3dd2347d..7b19f45cf 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts @@ -6,9 +6,18 @@ import { eq, fastAgentConversations, fastAgentMessages, + ensureSessionForFastConversation, + advanceSessionNotifiedCursor, + advanceSessionReadCursor, + getSessionForFastConversation, sql, + touchSessionActivity, type DatabaseOrTransaction, } from '@roomote/db/server'; +import { + evaluateDeploymentFeatureFlag, + FeatureFlag, +} from '@roomote/feature-flags/server'; import { fastAgentConversationSchema } from '@roomote/types'; import type { FastAgentConversation } from './fast-agent-conversation'; @@ -56,6 +65,10 @@ export interface FastAgentConversationRepository { }): Promise; } +async function sessionsDataEnabled(): Promise { + return evaluateDeploymentFeatureFlag(FeatureFlag.SessionsData); +} + function buildIdentityKey(conversation: FastAgentConversation): string { return `${conversation.surface}:${conversation.workspaceId}:${conversation.conversationId}`; } @@ -174,6 +187,7 @@ async function loadConversationRecord( export const fastAgentConversationRepository: FastAgentConversationRepository = { async getOrCreate({ userId, conversation }) { + const createSession = await sessionsDataEnabled(); return db.transaction(async (tx) => { await tx.execute( sql`select pg_advisory_xact_lock(hashtextextended(${buildIdentityKey(conversation)}, 0))`, @@ -245,6 +259,10 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = .where(eq(fastAgentConversations.id, record.id)) .returning(); + if (createSession) { + await ensureSessionForFastConversation(tx, updated?.id ?? record.id); + } + return loadConversationRecord(tx, updated?.id ?? record.id); }); }, @@ -326,6 +344,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = return; } + const touchSession = await sessionsDataEnabled(); await db.transaction(async (tx) => { const conversationId = await resolveCanonicalId(tx, requestedId); await tx.execute( @@ -342,10 +361,25 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = if (!updated) { throw new Error('Fast conversation was not found.'); } + if (touchSession) { + const session = await getSessionForFastConversation( + tx, + conversationId, + ); + if (session) { + await touchSessionActivity( + tx, + session.id, + Math.floor(Date.now() / 1000), + { recomputeStatus: false }, + ); + } + } }); }, async upsertMessage({ conversationId: requestedId, message }) { + const touchSession = await sessionsDataEnabled(); await db.transaction(async (tx) => { const conversationId = await resolveCanonicalId(tx, requestedId); await tx.execute( @@ -387,6 +421,35 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = .update(fastAgentConversations) .set({ updatedAt: sql`now()` }) .where(eq(fastAgentConversations.id, conversationId)); + if (touchSession) { + const session = await getSessionForFastConversation( + tx, + conversationId, + ); + if (session) { + await touchSessionActivity( + tx, + session.id, + Math.floor(message.ts / 1000), + { recomputeStatus: false }, + ); + const messageUserId = message.metadata?.userId; + if (message.role === 'user' && typeof messageUserId === 'string') { + await advanceSessionReadCursor(tx, { + sessionId: session.id, + userId: messageUserId, + eventAt: message.ts, + eventId: message.eventId, + }); + } else if (message.role === 'assistant') { + await advanceSessionNotifiedCursor(tx, { + sessionId: session.id, + eventAt: message.ts, + eventId: message.eventId, + }); + } + } + } }); }, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index cbdf8622c..b93006bca 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -23,8 +23,15 @@ import { appendFastAgentMemory, db, getDeploymentTaskModelOptions, + getSessionForFastConversation, + getSessionForTask, isBrainProviderConfigured, + touchSessionActivity, } from '@roomote/db/server'; +import { + evaluateDeploymentFeatureFlag, + FeatureFlag, +} from '@roomote/feature-flags/server'; import { Env } from '@roomote/env'; import { z } from 'zod'; @@ -119,6 +126,17 @@ const showWidgetArgsSchema = z.object({ const FAST_AGENT_DEFAULT_SLACK_HISTORY_LOOKBACK_MS = 24 * 60 * 60 * 1000; const FAST_AGENT_CANONICAL_TOOL_OUTPUT_MAX_CHARS = 50_000; +async function setFastSessionResponding( + fastConversationId: string, + responding: boolean, +): Promise { + const session = await getSessionForFastConversation(db, fastConversationId); + if (!session) return; + await touchSessionActivity(db, session.id, Math.floor(Date.now() / 1000), { + conversationResponding: responding, + }); +} + function buildFastAgentTurnId({ currentMessageId, conversation, @@ -973,6 +991,11 @@ export async function answerFastAgentQuestion({ }), ]); canonicalConversationId = session.id; + await setFastSessionResponding(session.id, true).catch((error) => { + console.warn( + `[sessions] Failed to mark Fast Session active: ${formatErrorForLog(error)}`, + ); + }); durableOpenCodeSessionId = session.openCodeSessionId; activeOpenCodeSessionId = session.openCodeSessionId; diagnostics.setCanonicalConversationId(session.id); @@ -1509,12 +1532,32 @@ export async function answerFastAgentQuestion({ taskUrl?: string; taskLinkRendered?: boolean; }) => { + let sessionCommsEnabled = false; + let linkedSession: Awaited> = + null; + try { + sessionCommsEnabled = await evaluateDeploymentFeatureFlag( + FeatureFlag.SessionsComms, + ); + linkedSession = sessionCommsEnabled + ? await getSessionForTask(db, task.taskId) + : null; + } catch (error) { + console.warn( + `[sessions] Failed to resolve Session kickoff link: ${formatErrorForLog(error)}`, + ); + } + const destinationUrl = linkedSession + ? `${Env.R_APP_URL}/sessions/${linkedSession.id}?task=${task.taskId}` + : task.taskUrl; const message = [ - args.kickoffMessage, - task.taskUrl && + sessionCommsEnabled + ? `Preparing workspace…\n\n${args.kickoffMessage}` + : args.kickoffMessage, + destinationUrl && !task.taskLinkRendered && - !args.kickoffMessage.includes(task.taskUrl) - ? `[Open the task](${task.taskUrl})` + !args.kickoffMessage.includes(destinationUrl) + ? `[${sessionCommsEnabled ? 'Open in Roomote' : 'Open the task'}](${destinationUrl})` : undefined, ] .filter((part): part is string => Boolean(part)) @@ -1762,6 +1805,7 @@ export async function answerFastAgentQuestion({ return await generateTrackedNonTaskTextInOpenCodeSession( { userId, + fastConversationId: session.id, surface: NON_TASK_INFERENCE_SURFACES.fastAgentQuestionAnswering, modelRole: FAST_AGENT_MODEL_ROLE, @@ -2022,6 +2066,15 @@ export async function answerFastAgentQuestion({ } return lastVisibleMessage || message; } finally { + if (canonicalConversationId) { + await setFastSessionResponding(canonicalConversationId, false).catch( + (error) => { + console.warn( + `[sessions] Failed to settle Fast Session status: ${formatErrorForLog(error)}`, + ); + }, + ); + } diagnostics.finish(); } } diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts index ccbf50880..f6d73e832 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts @@ -195,6 +195,7 @@ export function createFastAgentWebTaskLauncher(params: { userId: params.userId, surface: 'web', taskUrlCampaign: 'fast-delegation', + rendersTaskLink: true, buildTask: ({ prompt, environmentId, model, parentSessionId }) => ({ type: TaskPayloadKind.StandardTask, payload: { diff --git a/packages/cloud-agents/src/server/non-task-provider-usage.ts b/packages/cloud-agents/src/server/non-task-provider-usage.ts index 273f0fbf7..4e8d96394 100644 --- a/packages/cloud-agents/src/server/non-task-provider-usage.ts +++ b/packages/cloud-agents/src/server/non-task-provider-usage.ts @@ -91,6 +91,7 @@ export type NonTaskInferenceTrackingInput = { surface: string; userId?: string | null; taskId?: string | null; + fastConversationId?: string | null; provider?: string; }; @@ -359,6 +360,9 @@ async function recordNonTaskOpenCodeUsage( usageType: 'inference', eventKey: `non-task:${params.surface}:${harnessSessionId}:${messageId}`, taskId: params.taskId ?? null, + ...(params.fastConversationId + ? { fastConversationId: params.fastConversationId } + : {}), userId: params.userId ?? null, harnessSessionId, messageId, diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 28553bb41..5466f6fa7 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -48,6 +48,7 @@ import { db, deploymentSettings, ensureAutomationRowsOnce, + ensureSessionForTask, isChatGptSubscriptionConnected, createTaskWithRetry, markTaskStartParallelCountEndedAt, @@ -71,6 +72,10 @@ import { resolveWorkspaceRepositoryProviders, sql, } from '@roomote/db/server'; +import { + evaluateDeploymentFeatureFlag, + FeatureFlag, +} from '@roomote/feature-flags/server'; import { type Redis, getRedis } from '@roomote/redis'; import { captureActivationTaskCreated, @@ -1385,6 +1390,9 @@ async function enqueueFreshLaunch( const { task, initiator, workflow, surface, trigger } = input; const visibility: TaskVisibility = input.visibility ?? 'visible'; const linkedUserId = getTaskInitiatorLinkedUserId(initiator); + const sessionsDataEnabled = await evaluateDeploymentFeatureFlag( + FeatureFlag.SessionsData, + ); await assertUserIsNotDeleted(linkedUserId); @@ -1637,6 +1645,16 @@ async function enqueueFreshLaunch( }); if (activeRun) { + if (sessionsDataEnabled) { + await ensureSessionForTask(tx, { + taskId: existingTask.id, + fastConversationId: + getFastAgentParentFromPayload(taskWithHarnessOverrides.payload) + ?.sessionId ?? null, + origin: 'follow_up', + existingTaskReused: true, + }); + } return { taskRun: activeRun, createdRun: false, reusedTask: true }; } @@ -1686,6 +1704,22 @@ async function enqueueFreshLaunch( taskId = createdTask.id; } + if (sessionsDataEnabled) { + const fastParent = getFastAgentParentFromPayload( + taskWithHarnessOverrides.payload, + ); + await ensureSessionForTask(tx, { + taskId, + fastConversationId: fastParent?.sessionId ?? null, + origin: fastParent + ? 'fast_delegation' + : existingTask + ? 'follow_up' + : 'direct_launch', + existingTaskReused: Boolean(existingTask), + }); + } + if (input.prLinkage) { const prLinkage = { sourceControlProvider: input.prLinkage.provider, @@ -1802,6 +1836,20 @@ async function enqueueFreshLaunch( return taskRun; } + if (sessionsDataEnabled) { + const delegated = Boolean( + reusedTask || + getFastAgentParentFromPayload(taskWithHarnessOverrides.payload), + ); + void captureEvent( + delegated ? 'session_task_delegated' : 'session_created', + { + ...(linkedUserId ? { userId: linkedUserId } : {}), + properties: { surface, outcome: 'created' }, + }, + ); + } + if (shouldCaptureTaskCreatedEvent(taskRun.payloadKind)) { // Anonymous analytics (no-op unless enabled): task creation with // non-identifying routing facts only. diff --git a/packages/db/drizzle/0063_peaceful_stature.sql b/packages/db/drizzle/0063_peaceful_stature.sql new file mode 100644 index 000000000..446cd427a --- /dev/null +++ b/packages/db/drizzle/0063_peaceful_stature.sql @@ -0,0 +1,89 @@ +CREATE TABLE "session_backfill_state" ( + "key" text PRIMARY KEY NOT NULL, + "phase" text DEFAULT 'fast_conversations' NOT NULL, + "cursor_created_at" timestamp, + "cursor_id" text, + "completed_at" timestamp, + "last_run_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "session_backfill_state_phase_check" CHECK ("session_backfill_state"."phase" in ('fast_conversations', 'fast_tasks', 'tasks', 'participants')), + CONSTRAINT "session_backfill_state_cursor_shape_check" CHECK (("session_backfill_state"."cursor_created_at" IS NULL) = ("session_backfill_state"."cursor_id" IS NULL)) +); +--> statement-breakpoint +CREATE TABLE "session_participants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "last_read_event_at" bigint, + "last_read_event_id" text, + "last_notified_event_at" bigint, + "last_notified_event_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "session_participants_role_check" CHECK ("session_participants"."role" in ('owner', 'member')) +); +--> statement-breakpoint +CREATE TABLE "session_pins" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session_tasks" ( + "session_id" uuid NOT NULL, + "task_id" text NOT NULL, + "attached_at" timestamp DEFAULT now() NOT NULL, + "origin" text NOT NULL, + CONSTRAINT "session_tasks_session_id_task_id_pk" PRIMARY KEY("session_id","task_id"), + CONSTRAINT "session_tasks_origin_check" CHECK ("session_tasks"."origin" in ('direct_launch', 'fast_delegation', 'backfill', 'follow_up')) +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" text NOT NULL, + "owner_kind" text NOT NULL, + "owner_user_id" text, + "owner_automation" text, + "source_surface" text NOT NULL, + "source_trigger" text NOT NULL, + "fast_conversation_id" uuid, + "visibility" text DEFAULT 'visible' NOT NULL, + "activity_at" bigint NOT NULL, + "cached_status" text, + "archived_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sessions_owner_shape_check" CHECK (("sessions"."owner_kind" = 'user' AND "sessions"."owner_automation" IS NULL) OR ("sessions"."owner_kind" = 'automation' AND "sessions"."owner_user_id" IS NULL) OR ("sessions"."owner_kind" = 'system' AND "sessions"."owner_user_id" IS NULL AND "sessions"."owner_automation" IS NULL)), + CONSTRAINT "sessions_owner_kind_check" CHECK ("sessions"."owner_kind" in ('user', 'automation', 'system')), + CONSTRAINT "sessions_source_surface_check" CHECK ("sessions"."source_surface" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')), + CONSTRAINT "sessions_source_trigger_check" CHECK ("sessions"."source_trigger" in ('message', 'webhook', 'schedule', 'manual')), + CONSTRAINT "sessions_visibility_check" CHECK ("sessions"."visibility" in ('visible', 'hidden')), + CONSTRAINT "sessions_cached_status_check" CHECK ("sessions"."cached_status" IS NULL OR "sessions"."cached_status" in ('active', 'needs_input', 'blocked', 'ready')) +); +--> statement-breakpoint +ALTER TABLE "task_inference_usage_events" ADD COLUMN "session_id" uuid;--> statement-breakpoint +ALTER TABLE "session_participants" ADD CONSTRAINT "session_participants_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_participants" ADD CONSTRAINT "session_participants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_pins" ADD CONSTRAINT "session_pins_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_pins" ADD CONSTRAINT "session_pins_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_tasks" ADD CONSTRAINT "session_tasks_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_tasks" ADD CONSTRAINT "session_tasks_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_owner_automation_automations_key_fk" FOREIGN KEY ("owner_automation") REFERENCES "public"."automations"("key") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_fast_conversation_id_fast_agent_conversations_id_fk" FOREIGN KEY ("fast_conversation_id") REFERENCES "public"."fast_agent_conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "session_participants_session_user_unique" ON "session_participants" USING btree ("session_id","user_id");--> statement-breakpoint +CREATE INDEX "session_participants_user_id_idx" ON "session_participants" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "session_pins_user_session_unique" ON "session_pins" USING btree ("user_id","session_id");--> statement-breakpoint +CREATE INDEX "session_pins_user_updated_at_idx" ON "session_pins" USING btree ("user_id","updated_at");--> statement-breakpoint +CREATE INDEX "session_pins_session_id_idx" ON "session_pins" USING btree ("session_id");--> statement-breakpoint +CREATE UNIQUE INDEX "session_tasks_task_id_unique" ON "session_tasks" USING btree ("task_id");--> statement-breakpoint +CREATE INDEX "session_tasks_session_attached_at_idx" ON "session_tasks" USING btree ("session_id","attached_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "sessions_visibility_activity_at_idx" ON "sessions" USING btree ("visibility","activity_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "sessions_owner_user_id_idx" ON "sessions" USING btree ("owner_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "sessions_fast_conversation_id_unique" ON "sessions" USING btree ("fast_conversation_id") WHERE "sessions"."fast_conversation_id" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "task_inference_usage_events" ADD CONSTRAINT "task_inference_usage_events_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "task_inference_usage_events_session_id_idx" ON "task_inference_usage_events" USING btree ("session_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0063_snapshot.json b/packages/db/drizzle/meta/0063_snapshot.json new file mode 100644 index 000000000..9bd9d226f --- /dev/null +++ b/packages/db/drizzle/meta/0063_snapshot.json @@ -0,0 +1,13890 @@ +{ + "id": "2d190b40-e7c6-41ea-8e73-b88c561978a8", + "prevId": "7d4b9ad0-0598-4d83-be76-5f7b814cbf7d", + "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.brain_collector_items": { + "name": "brain_collector_items", + "schema": "", + "columns": { + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_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": { + "brain_collector_items_collector_seen_idx": { + "name": "brain_collector_items_collector_seen_idx", + "columns": [ + { + "expression": "collector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "brain_collector_items_collector_item_pk": { + "name": "brain_collector_items_collector_item_pk", + "columns": ["collector_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_memory_events": { + "name": "brain_memory_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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "agent_summary": { + "name": "agent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_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": { + "brain_memory_events_status_created_idx": { + "name": "brain_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brain_memory_events_run_id_task_runs_id_fk": { + "name": "brain_memory_events_run_id_task_runs_id_fk", + "tableFrom": "brain_memory_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_memory_events_run_unique": { + "name": "brain_memory_events_run_unique", + "nullsNotDistinct": false, + "columns": ["run_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_sync_state": { + "name": "brain_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "watermark": { + "name": "watermark", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_cursor": { + "name": "backfill_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_sync_state_collector_id_unique": { + "name": "brain_sync_state_collector_id_unique", + "nullsNotDistinct": false, + "columns": ["collector_id"] + } + }, + "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.custom_automations": { + "name": "custom_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule_mode": { + "name": "schedule_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "all_repositories": { + "name": "all_repositories", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sandbox_task'" + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "last_launched_task_id": { + "name": "last_launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_claimed_at": { + "name": "launch_claimed_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": { + "custom_automations_name_unique_idx": { + "name": "custom_automations_name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_enabled_idx": { + "name": "custom_automations_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_environment_id_idx": { + "name": "custom_automations_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_automations_environment_id_environments_id_fk": { + "name": "custom_automations_environment_id_environments_id_fk", + "tableFrom": "custom_automations", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_created_by_user_id_users_id_fk": { + "name": "custom_automations_created_by_user_id_users_id_fk", + "tableFrom": "custom_automations", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_last_launched_task_id_tasks_id_fk": { + "name": "custom_automations_last_launched_task_id_tasks_id_fk", + "tableFrom": "custom_automations", + "tableTo": "tasks", + "columnsFrom": ["last_launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_mcp_servers": { + "name": "custom_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stdio": { + "name": "stdio", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "manual_client_id": { + "name": "manual_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_client_secret": { + "name": "manual_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata": { + "name": "oauth_server_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata_fetched_at": { + "name": "oauth_server_metadata_fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_resource_indicator_disabled": { + "name": "oauth_resource_indicator_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_mcp_servers_created_by_user_id_users_id_fk": { + "name": "custom_mcp_servers_created_by_user_id_users_id_fk", + "tableFrom": "custom_mcp_servers", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_mcp_servers_name_unique": { + "name": "custom_mcp_servers_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "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 + }, + "tool_access_mode": { + "name": "tool_access_mode", + "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 + }, + "workspace_routing_settings": { + "name": "workspace_routing_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_provider": { + "name": "router_debug_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_channel_id": { + "name": "router_debug_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_disabled": { + "name": "router_debug_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "license_cloud_state": { + "name": "license_cloud_state", + "type": "jsonb", + "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 + }, + "manager_discord_channel_id": { + "name": "manager_discord_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone_updated_at": { + "name": "time_zone_updated_at", + "type": "timestamp", + "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.discord_gateway_sessions": { + "name": "discord_gateway_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resume_gateway_url": { + "name": "resume_gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shard_count": { + "name": "shard_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_ack_at": { + "name": "last_heartbeat_ack_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installation_channels": { + "name": "discord_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_installation_id": { + "name": "discord_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_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": { + "discord_installation_channels_installation_id_idx": { + "name": "discord_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installation_channels_unique": { + "name": "discord_installation_channels_unique", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installation_channels_discord_installation_id_discord_installations_id_fk": { + "name": "discord_installation_channels_discord_installation_id_discord_installations_id_fk", + "tableFrom": "discord_installation_channels", + "tableTo": "discord_installations", + "columnsFrom": ["discord_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installations": { + "name": "discord_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guild_name": { + "name": "guild_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_id": { + "name": "default_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_name": { + "name": "default_channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_type": { + "name": "default_channel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": { + "discord_installations_guild_id_unique": { + "name": "discord_installations_guild_id_unique", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_active_idx": { + "name": "discord_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_default_channel_idx": { + "name": "discord_installations_default_channel_idx", + "columns": [ + { + "expression": "default_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installations_installed_by_user_id_users_id_fk": { + "name": "discord_installations_installed_by_user_id_users_id_fk", + "tableFrom": "discord_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_user_mappings": { + "name": "discord_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_dm_channel_id": { + "name": "discord_dm_channel_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": { + "discord_user_mappings_user_id_idx": { + "name": "discord_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_user_mappings_discord_user_id_unique": { + "name": "discord_user_mappings_discord_user_id_unique", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_user_mappings_user_id_users_id_fk": { + "name": "discord_user_mappings_user_id_users_id_fk", + "tableFrom": "discord_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": false + }, + "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.fast_agent_conversations": { + "name": "fast_agent_conversations", + "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 + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_reply_channel_id": { + "name": "current_reply_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_thread_id": { + "name": "current_reply_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_service_url": { + "name": "current_reply_service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_target_verified": { + "name": "reply_target_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "compatibility_messages": { + "name": "compatibility_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "legacy_conversation_ids": { + "name": "legacy_conversation_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "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": { + "fast_agent_conversations_identity_unique": { + "name": "fast_agent_conversations_identity_unique", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_user_idx": { + "name": "fast_agent_conversations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_legacy_ids_idx": { + "name": "fast_agent_conversations_legacy_ids_idx", + "columns": [ + { + "expression": "legacy_conversation_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_conversations_user_id_users_id_fk": { + "name": "fast_agent_conversations_user_id_users_id_fk", + "tableFrom": "fast_agent_conversations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_memory_events": { + "name": "fast_agent_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "memory": { + "name": "memory", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_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": { + "fast_agent_memory_events_status_created_idx": { + "name": "fast_agent_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_memory_events", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fast_agent_memory_events_conversation_unique": { + "name": "fast_agent_memory_events_conversation_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_messages": { + "name": "fast_agent_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_seq": { + "name": "turn_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_session_id": { + "name": "native_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_message_id": { + "name": "native_message_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": { + "fast_agent_messages_conversation_event_unique": { + "name": "fast_agent_messages_conversation_event_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_messages_conversation_order_idx": { + "name": "fast_agent_messages_conversation_order_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_pr_feedback_deliveries": { + "name": "fast_agent_pr_feedback_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_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": { + "fast_agent_pr_feedback_deliveries_identity_unique": { + "name": "fast_agent_pr_feedback_deliveries_identity_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_pr_feedback_deliveries_task_idx": { + "name": "fast_agent_pr_feedback_deliveries_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_provider_messages": { + "name": "fast_agent_provider_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_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": { + "fast_agent_provider_messages_route_unique": { + "name": "fast_agent_provider_messages_route_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_conversation_idx": { + "name": "fast_agent_provider_messages_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_thread_idx": { + "name": "fast_agent_provider_messages_thread_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_provider_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_agent_provider_messages_provider_check": { + "name": "fast_agent_provider_messages_provider_check", + "value": "\"fast_agent_provider_messages\".\"provider\" in ('discord', 'teams')" + } + }, + "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.license_usage_observations": { + "name": "license_usage_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active_users": { + "name": "active_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_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": { + "license_usage_observations_pending_idx": { + "name": "license_usage_observations_pending_idx", + "columns": [ + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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.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'" + }, + "usage_type": { + "name": "usage_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inference'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "pricing_metadata": { + "name": "pricing_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_event_key_unique": { + "name": "task_inference_usage_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "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_user_id_idx": { + "name": "task_inference_usage_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_environment_id_idx": { + "name": "task_inference_usage_events_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_session_id_idx": { + "name": "task_inference_usage_events_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_provider_model_idx": { + "name": "task_inference_usage_events_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_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" + }, + "task_inference_usage_events_user_id_users_id_fk": { + "name": "task_inference_usage_events_user_id_users_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_environment_id_environments_id_fk": { + "name": "task_inference_usage_events_environment_id_environments_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_session_id_sessions_id_fk": { + "name": "task_inference_usage_events_session_id_sessions_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.notion_directory_users": { + "name": "notion_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notion_user_id": { + "name": "notion_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notion_directory_users_unique": { + "name": "notion_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["notion_user_id"] + } + }, + "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.pr_review_auto_preferences": { + "name": "pr_review_auto_preferences", + "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 + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_destination_key": { + "name": "source_destination_key", + "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": { + "pr_review_auto_preferences_identity_unique": { + "name": "pr_review_auto_preferences_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_auto_preferences_repository_idx": { + "name": "pr_review_auto_preferences_repository_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": { + "pr_review_auto_preferences_repository_id_repositories_id_fk": { + "name": "pr_review_auto_preferences_repository_id_repositories_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_enabled_by_user_id_users_id_fk": { + "name": "pr_review_auto_preferences_enabled_by_user_id_users_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_source_task_id_tasks_id_fk": { + "name": "pr_review_auto_preferences_source_task_id_tasks_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_cycles": { + "name": "pr_review_cycles", + "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 + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pr_review_cycles_source_unique": { + "name": "pr_review_cycles_source_unique", + "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" + }, + { + "expression": "review_head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_event_deliveries": { + "name": "pr_review_event_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_event_deliveries_event_task_unique": { + "name": "pr_review_event_deliveries_event_task_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_event_deliveries_due_idx": { + "name": "pr_review_event_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_event_deliveries_event_id_pr_review_events_id_fk": { + "name": "pr_review_event_deliveries_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_event_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_event_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_event_deliveries_status_check": { + "name": "pr_review_event_deliveries_status_check", + "value": "\"pr_review_event_deliveries\".\"status\" in ('pending', 'processing', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_events": { + "name": "pr_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "batch_kind": { + "name": "batch_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "superseded": { + "name": "superseded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_events_source_unique": { + "name": "pr_review_events_source_unique", + "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" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_events_pr_idx": { + "name": "pr_review_events_pr_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_events_batch_kind_check": { + "name": "pr_review_events_batch_kind_check", + "value": "\"pr_review_events\".\"batch_kind\" in ('human', 'roomote')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_deliveries": { + "name": "pr_review_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_unit_id": { + "name": "notification_unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_kind": { + "name": "destination_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_key": { + "name": "destination_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "route_provider": { + "name": "route_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_workspace_id": { + "name": "route_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_channel_id": { + "name": "route_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_thread_id": { + "name": "route_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follow_up_prompt": { + "name": "follow_up_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_task_id": { + "name": "target_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_claimed_at": { + "name": "action_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dispatch_key": { + "name": "dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dispatched_run_id": { + "name": "dispatched_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "pr_review_notification_deliveries_destination_unique": { + "name": "pr_review_notification_deliveries_destination_unique", + "columns": [ + { + "expression": "notification_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_dispatch_key_unique": { + "name": "pr_review_notification_deliveries_dispatch_key_unique", + "columns": [ + { + "expression": "dispatch_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_due_idx": { + "name": "pr_review_notification_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_destination_idx": { + "name": "pr_review_notification_deliveries_destination_idx", + "columns": [ + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["notification_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_target_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_target_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["target_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_acting_user_id_users_id_fk": { + "name": "pr_review_notification_deliveries_acting_user_id_users_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_deliveries_destination_kind_check": { + "name": "pr_review_notification_deliveries_destination_kind_check", + "value": "\"pr_review_notification_deliveries\".\"destination_kind\" in ('fast_conversation', 'task')" + }, + "pr_review_notification_deliveries_status_check": { + "name": "pr_review_notification_deliveries_status_check", + "value": "\"pr_review_notification_deliveries\".\"status\" in ('pending', 'claimed', 'prepared', 'prompt_posting', 'awaiting_user_action', 'auto_dispatch_pending', 'completed', 'suppressed', 'dismissed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_unit_events": { + "name": "pr_review_notification_unit_events", + "schema": "", + "columns": { + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_unit_events_event_unique": { + "name": "pr_review_notification_unit_events_event_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_unit_events_event_id_pr_review_events_id_fk": { + "name": "pr_review_notification_unit_events_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pr_review_notification_unit_events_pk": { + "name": "pr_review_notification_unit_events_pk", + "columns": ["unit_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_notification_units": { + "name": "pr_review_notification_units", + "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 + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_identity_key": { + "name": "head_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_kind": { + "name": "episode_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "first_observed_at": { + "name": "first_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sealed_at": { + "name": "sealed_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": { + "pr_review_notification_units_identity_unique": { + "name": "pr_review_notification_units_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_units_open_head_idx": { + "name": "pr_review_notification_units_open_head_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" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sealed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_units_repository_id_repositories_id_fk": { + "name": "pr_review_notification_units_repository_id_repositories_id_fk", + "tableFrom": "pr_review_notification_units", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_units_episode_kind_check": { + "name": "pr_review_notification_units_episode_kind_check", + "value": "\"pr_review_notification_units\".\"episode_kind\" in ('roomote_cycle', 'human', 'automated', 'ci')" + } + }, + "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 + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_files": { + "name": "changed_files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_file_count": { + "name": "changed_file_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "files_capped": { + "name": "files_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reviews_capped": { + "name": "reviews_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enriched_at": { + "name": "enriched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enriched_for_updated_at": { + "name": "enriched_for_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_failed_at": { + "name": "enrichment_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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.repository_automation_signals": { + "name": "repository_automation_signals", + "schema": "", + "columns": { + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signals_version": { + "name": "signals_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "repository_automation_signals_collected_idx": { + "name": "repository_automation_signals_collected_idx", + "columns": [ + { + "expression": "collected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_automation_signals_repository_id_repositories_id_fk": { + "name": "repository_automation_signals_repository_id_repositories_id_fk", + "tableFrom": "repository_automation_signals", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "repository_automation_signals_repository_id_signals_version_pk": { + "name": "repository_automation_signals_repository_id_signals_version_pk", + "columns": ["repository_id", "signals_version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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.session_backfill_state": { + "name": "session_backfill_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fast_conversations'" + }, + "cursor_created_at": { + "name": "cursor_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_backfill_state_phase_check": { + "name": "session_backfill_state_phase_check", + "value": "\"session_backfill_state\".\"phase\" in ('fast_conversations', 'fast_tasks', 'tasks', 'participants')" + }, + "session_backfill_state_cursor_shape_check": { + "name": "session_backfill_state_cursor_shape_check", + "value": "(\"session_backfill_state\".\"cursor_created_at\" IS NULL) = (\"session_backfill_state\".\"cursor_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.session_participants": { + "name": "session_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "last_read_event_at": { + "name": "last_read_event_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_read_event_id": { + "name": "last_read_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_event_at": { + "name": "last_notified_event_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_notified_event_id": { + "name": "last_notified_event_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": { + "session_participants_session_user_unique": { + "name": "session_participants_session_user_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_participants_user_id_idx": { + "name": "session_participants_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_participants_session_id_sessions_id_fk": { + "name": "session_participants_session_id_sessions_id_fk", + "tableFrom": "session_participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_participants_user_id_users_id_fk": { + "name": "session_participants_user_id_users_id_fk", + "tableFrom": "session_participants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_participants_role_check": { + "name": "session_participants_role_check", + "value": "\"session_participants\".\"role\" in ('owner', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.session_pins": { + "name": "session_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "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": { + "session_pins_user_session_unique": { + "name": "session_pins_user_session_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_pins_user_updated_at_idx": { + "name": "session_pins_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": {} + }, + "session_pins_session_id_idx": { + "name": "session_pins_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_pins_session_id_sessions_id_fk": { + "name": "session_pins_session_id_sessions_id_fk", + "tableFrom": "session_pins", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_pins_user_id_users_id_fk": { + "name": "session_pins_user_id_users_id_fk", + "tableFrom": "session_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_tasks": { + "name": "session_tasks", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_tasks_task_id_unique": { + "name": "session_tasks_task_id_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_tasks_session_attached_at_idx": { + "name": "session_tasks_session_attached_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attached_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_tasks_session_id_sessions_id_fk": { + "name": "session_tasks_session_id_sessions_id_fk", + "tableFrom": "session_tasks", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_tasks_task_id_tasks_id_fk": { + "name": "session_tasks_task_id_tasks_id_fk", + "tableFrom": "session_tasks", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_tasks_session_id_task_id_pk": { + "name": "session_tasks_session_id_task_id_pk", + "columns": ["session_id", "task_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_tasks_origin_check": { + "name": "session_tasks_origin_check", + "value": "\"session_tasks\".\"origin\" in ('direct_launch', 'fast_delegation', 'backfill', 'follow_up')" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_kind": { + "name": "owner_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_automation": { + "name": "owner_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_surface": { + "name": "source_surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_trigger": { + "name": "source_trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fast_conversation_id": { + "name": "fast_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cached_status": { + "name": "cached_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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": { + "sessions_visibility_activity_at_idx": { + "name": "sessions_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_owner_user_id_idx": { + "name": "sessions_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_fast_conversation_id_unique": { + "name": "sessions_fast_conversation_id_unique", + "columns": [ + { + "expression": "fast_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sessions\".\"fast_conversation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_owner_user_id_users_id_fk": { + "name": "sessions_owner_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sessions_owner_automation_automations_key_fk": { + "name": "sessions_owner_automation_automations_key_fk", + "tableFrom": "sessions", + "tableTo": "automations", + "columnsFrom": ["owner_automation"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sessions_fast_conversation_id_fast_agent_conversations_id_fk": { + "name": "sessions_fast_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "sessions", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_conversation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sessions_owner_shape_check": { + "name": "sessions_owner_shape_check", + "value": "(\"sessions\".\"owner_kind\" = 'user' AND \"sessions\".\"owner_automation\" IS NULL) OR (\"sessions\".\"owner_kind\" = 'automation' AND \"sessions\".\"owner_user_id\" IS NULL) OR (\"sessions\".\"owner_kind\" = 'system' AND \"sessions\".\"owner_user_id\" IS NULL AND \"sessions\".\"owner_automation\" IS NULL)" + }, + "sessions_owner_kind_check": { + "name": "sessions_owner_kind_check", + "value": "\"sessions\".\"owner_kind\" in ('user', 'automation', 'system')" + }, + "sessions_source_surface_check": { + "name": "sessions_source_surface_check", + "value": "\"sessions\".\"source_surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')" + }, + "sessions_source_trigger_check": { + "name": "sessions_source_trigger_check", + "value": "\"sessions\".\"source_trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "sessions_visibility_check": { + "name": "sessions_visibility_check", + "value": "\"sessions\".\"visibility\" in ('visible', 'hidden')" + }, + "sessions_cached_status_check": { + "name": "sessions_cached_status_check", + "value": "\"sessions\".\"cached_status\" IS NULL OR \"sessions\".\"cached_status\" in ('active', 'needs_input', 'blocked', 'ready')" + } + }, + "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 + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_directory_users": { + "name": "slack_directory_users", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_app_user": { + "name": "is_app_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_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": { + "slack_directory_users_team_id_idx": { + "name": "slack_directory_users_team_id_idx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_directory_users_unique": { + "name": "slack_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_fast_integration_calls": { + "name": "slack_fast_integration_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fast_agent_conversation_id": { + "name": "fast_agent_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_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 + }, + "slack_message_ts": { + "name": "slack_message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "arguments": { + "name": "arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_preview": { + "name": "result_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "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_fast_integration_calls_conversation_idx": { + "name": "slack_fast_integration_calls_conversation_idx", + "columns": [ + { + "expression": "fast_agent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_user_idx": { + "name": "slack_fast_integration_calls_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_status_idx": { + "name": "slack_fast_integration_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk": { + "name": "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_agent_conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_fast_integration_calls_user_id_users_id_fk": { + "name": "slack_fast_integration_calls_user_id_users_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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_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.source_control_user_mappings": { + "name": "source_control_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 + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "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": { + "source_control_user_mappings_auth_account_unique": { + "name": "source_control_user_mappings_auth_account_unique", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_user_provider_host_idx": { + "name": "source_control_user_mappings_user_provider_host_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_provider_identity_unique": { + "name": "source_control_user_mappings_provider_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_control_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "source_control_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "source_control_user_mappings_user_id_auth_users_id_fk": { + "name": "source_control_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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_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 + }, + "created_by_roomote": { + "name": "created_by_roomote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mergeability_status": { + "name": "mergeability_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "conflict_detected_at": { + "name": "conflict_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notification_claimed_at": { + "name": "conflict_notification_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notified_at": { + "name": "conflict_notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auto_handle_feedback_by_user_id": { + "name": "auto_handle_feedback_by_user_id", + "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": {} + }, + "task_pull_requests_mergeability_lookup_idx": { + "name": "task_pull_requests_mergeability_lookup_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_roomote", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_base_ref", + "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" + }, + "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk": { + "name": "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "users", + "columnsFrom": ["auto_handle_feedback_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "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 + }, + "fast_agent_session_id": { + "name": "fast_agent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((payload ->> 'fastAgentSessionId')::uuid)", + "type": "stored" + } + }, + "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 + }, + "error_code": { + "name": "error_code", + "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_fast_agent_session_id_idx": { + "name": "task_runs_fast_agent_session_id_idx", + "columns": [ + { + "expression": "fast_agent_session_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_v2_idx": { + "name": "task_runs_sleep_check_due_v2_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', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_v2_idx": { + "name": "task_runs_sleep_check_stale_worker_v2_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', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_v2_idx": { + "name": "task_runs_sleep_check_active_v2_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', 'box', 'roomote', 'azure')", + "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_discord_source_event_unique": { + "name": "task_runs_discord_source_event_unique", + "columns": [ + { + "expression": "(\"payload\"->>'communicationSourceEventId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'communicationProvider' = 'discord' AND \"task_runs\".\"payload\"->>'communicationSourceEventId' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_launch_idempotency_key_unique": { + "name": "task_runs_launch_idempotency_key_unique", + "columns": [ + { + "expression": "(\"payload\"->>'launchIdempotencyKey')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'launchIdempotencyKey' IS NOT NULL", + "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 + }, + "goal_objective": { + "name": "goal_objective", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_status": { + "name": "goal_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_max_continuations": { + "name": "goal_max_continuations", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "goal_continuations_used": { + "name": "goal_continuations_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocked_reason": { + "name": "goal_blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_completed_at": { + "name": "goal_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "goal_last_continuation_id": { + "name": "goal_last_continuation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_continuation_ids": { + "name": "goal_continuation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_generation_ids": { + "name": "goal_generation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_blocker_candidate_reason": { + "name": "goal_blocker_candidate_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_blocker_candidate_count": { + "name": "goal_blocker_candidate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocker_last_continuation_used": { + "name": "goal_blocker_last_continuation_used", + "type": "integer", + "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', 'discord', '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_goal_status_check": { + "name": "tasks_goal_status_check", + "value": "\"tasks\".\"goal_status\" IS NULL OR \"tasks\".\"goal_status\" in ('active', 'complete', 'blocked', 'budget_limited')" + }, + "tasks_goal_continuations_check": { + "name": "tasks_goal_continuations_check", + "value": "\"tasks\".\"goal_continuations_used\" >= 0 AND (\"tasks\".\"goal_max_continuations\" IS NULL OR \"tasks\".\"goal_max_continuations\" > 0)" + }, + "tasks_goal_blocker_candidate_count_check": { + "name": "tasks_goal_blocker_candidate_count_check", + "value": "\"tasks\".\"goal_blocker_candidate_count\" >= 0" + }, + "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 + }, + "cookie_consented_at": { + "name": "cookie_consented_at", + "type": "timestamp", + "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 228f1e8f0..d4787886c 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1787776112381, "tag": "0062_neat_lady_deathstrike", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1787824023305, + "tag": "0063_peaceful_stature", + "breakpoints": true } ] } diff --git a/packages/db/src/__tests__/schema-constraints.test.ts b/packages/db/src/__tests__/schema-constraints.test.ts index 088202a4b..630c51dd6 100644 --- a/packages/db/src/__tests__/schema-constraints.test.ts +++ b/packages/db/src/__tests__/schema-constraints.test.ts @@ -28,10 +28,15 @@ import { import { db, + fastAgentConversations, inArray, repositories, repositoryFactory, runFactory, + sessionFactory, + sessionParticipants, + sessions, + sessionTasks, taskFactory, tasks, userFactory, @@ -43,6 +48,7 @@ const createdTaskIds: string[] = []; const createdUserIds: string[] = []; const createdRepositoryIds: string[] = []; const createdWebhookIds: string[] = []; +const createdSessionIds: string[] = []; afterAll(async () => { if (createdWebhookIds.length > 0) { @@ -55,6 +61,10 @@ afterAll(async () => { .where(inArray(repositories.id, createdRepositoryIds)); } + if (createdSessionIds.length > 0) { + await db.delete(sessions).where(inArray(sessions.id, createdSessionIds)); + } + if (createdTaskIds.length > 0) { // task_runs cascade from tasks. await db.delete(tasks).where(inArray(tasks.id, createdTaskIds)); @@ -71,6 +81,14 @@ async function createTask(overrides: Parameters[0]) { return task; } +async function createSession( + overrides: Parameters[0], +) { + const session = await sessionFactory.create(overrides); + createdSessionIds.push(session.id); + return session; +} + /** * Asserts the promise rejects with a Postgres violation of the named * constraint. Drizzle wraps driver errors in DrizzleQueryError, so the @@ -167,6 +185,124 @@ describe('tasks classification CHECK constraints', () => { ); }); +describe('sessions CHECK and uniqueness constraints', () => { + it.each([ + ['ownerKind', 'sessions_owner_kind_check'], + ['sourceSurface', 'sessions_source_surface_check'], + ['sourceTrigger', 'sessions_source_trigger_check'], + ['visibility', 'sessions_visibility_check'], + ['cachedStatus', 'sessions_cached_status_check'], + ] as const)( + 'rejects an unknown %s value via %s', + async (field, constraintName) => { + const overrides = { + [field]: 'not-a-real-vocabulary-value', + } as unknown as Parameters[0]; + + await expectConstraintViolation(createSession(overrides), constraintName); + }, + ); + + it('enforces the owner shape', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + + await expectConstraintViolation( + createSession({ ownerKind: 'system', ownerUserId: user.id }), + 'sessions_owner_shape_check', + ); + }); + + it('allows only one canonical session per task', async () => { + const task = await createTask({}); + const first = await createSession({}); + const second = await createSession({}); + + await db.insert(sessionTasks).values({ + sessionId: first.id, + taskId: task.id, + origin: 'direct_launch', + }); + await expectConstraintViolation( + db.insert(sessionTasks).values({ + sessionId: second.id, + taskId: task.id, + origin: 'follow_up', + }), + 'session_tasks_task_id_unique', + ); + }); + + it('rejects unknown task-link origins', async () => { + const task = await createTask({}); + const session = await createSession({}); + + await expectConstraintViolation( + db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'not-a-real-origin' as 'direct_launch', + }), + 'session_tasks_origin_check', + ); + }); + + it('allows only one participant row per session and user', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const session = await createSession({}); + + await db.insert(sessionParticipants).values({ + sessionId: session.id, + userId: user.id, + role: 'member', + }); + await expectConstraintViolation( + db.insert(sessionParticipants).values({ + sessionId: session.id, + userId: user.id, + role: 'owner', + }), + 'session_participants_session_user_unique', + ); + }); + + it('rejects unknown participant roles', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const session = await createSession({}); + + await expectConstraintViolation( + db.insert(sessionParticipants).values({ + sessionId: session.id, + userId: user.id, + role: 'not-a-real-role' as 'member', + }), + 'session_participants_role_check', + ); + }); + + it('allows only one session per Fast conversation', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: `workspace-${randomUUID()}`, + conversationId: `conversation-${randomUUID()}`, + }) + .returning(); + + await createSession({ fastConversationId: conversation!.id }); + await expectConstraintViolation( + createSession({ fastConversationId: conversation!.id }), + 'sessions_fast_conversation_id_unique', + ); + }); +}); + describe('task_runs classification CHECK constraints', () => { it('accepts every run kind and harness', async () => { for (const kind of RUN_KINDS) { diff --git a/packages/db/src/fixtures/factories/index.ts b/packages/db/src/fixtures/factories/index.ts index aaa18bf5c..f1e3374a1 100644 --- a/packages/db/src/fixtures/factories/index.ts +++ b/packages/db/src/fixtures/factories/index.ts @@ -1,5 +1,6 @@ export { userFactory } from './user.factory'; export { taskFactory } from './task.factory'; +export { sessionFactory } from './session.factory'; export { githubInstallationFactory } from './githubInstallation.factory'; export { slackInstallationFactory } from './slackInstallation.factory'; export { slackUserMappingFactory } from './slackUserMapping.factory'; diff --git a/packages/db/src/fixtures/factories/session.factory.ts b/packages/db/src/fixtures/factories/session.factory.ts new file mode 100644 index 000000000..b93d3bc02 --- /dev/null +++ b/packages/db/src/fixtures/factories/session.factory.ts @@ -0,0 +1,36 @@ +import { faker } from '@faker-js/faker'; +import { Factory } from 'fishery'; + +import { type DatabaseOrTransaction, db } from '../../db'; +import { sessions } from '../../schema'; +import type { CreateSession, Session } from '../../types'; + +export const sessionFactory = Factory.define< + CreateSession, + { db?: DatabaseOrTransaction }, + Session +>(({ params, onCreate, transientParams }) => { + onCreate(async (values) => { + const [inserted] = await (transientParams.db || db) + .insert(sessions) + .values(values) + .returning(); + + if (!inserted) { + throw new Error('Failed to insert session'); + } + + return inserted; + }); + + return { + title: faker.lorem.sentence(), + ownerKind: 'system', + sourceSurface: 'system', + sourceTrigger: 'manual', + visibility: 'visible', + activityAt: Math.floor(Date.now() / 1000), + cachedStatus: 'ready', + ...params, + } satisfies CreateSession; +}); diff --git a/packages/db/src/lib/__tests__/sessions.test.ts b/packages/db/src/lib/__tests__/sessions.test.ts new file mode 100644 index 000000000..a4f88241f --- /dev/null +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -0,0 +1,471 @@ +import { + db, + eq, + fastAgentConversations, + llmUsageEvents, + recordLlmUsage, + sessionFactory, + sessionParticipants, + sessions, + sessionTasks, + taskFactory, + tasks, + userFactory, + users, +} from '../../server'; + +import { + advanceSessionReadCursor, + advanceSessionNotifiedCursor, + deriveSessionStatus, + ensureSessionForFastConversation, + ensureSessionForTask, + touchSessionActivity, +} from '../sessions'; + +const createdTaskIds: string[] = []; +const createdSessionIds: string[] = []; +const createdConversationIds: string[] = []; +const createdUserIds: string[] = []; + +afterEach(async () => { + if (createdSessionIds.length > 0) { + await db.delete(sessions).where(eq(sessions.id, createdSessionIds.pop()!)); + } + while (createdTaskIds.length > 0) { + await db.delete(tasks).where(eq(tasks.id, createdTaskIds.pop()!)); + } + while (createdConversationIds.length > 0) { + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, createdConversationIds.pop()!)); + } + while (createdUserIds.length > 0) { + await db.delete(users).where(eq(users.id, createdUserIds.pop()!)); + } +}); + +describe('deriveSessionStatus', () => { + const task = ( + overrides: Partial< + Parameters[0]['tasks'][number] + > = {}, + ) => ({ + state: 'completed' as const, + taskPhase: null, + goalStatus: null, + ...overrides, + }); + + it('prioritizes needs input over responding conversation and active work', () => { + expect( + deriveSessionStatus({ + conversationResponding: true, + tasks: [task({ state: 'active', taskPhase: 'waiting_for_user_input' })], + }), + ).toBe('needs_input'); + }); + + it.each([ + ['a responding conversation', true, [task()], 'active'], + ['an active task', false, [task({ state: 'active' })], 'active'], + ['a failed task', false, [task({ state: 'failed' })], 'blocked'], + ['a blocked goal', false, [task({ goalStatus: 'blocked' })], 'blocked'], + [ + 'a budget-limited goal', + false, + [task({ goalStatus: 'budget_limited' })], + 'blocked', + ], + ['only settled work', false, [task()], 'ready'], + ['no work', false, [], 'ready'], + ] as const)('derives %s as %s', (_label, responding, taskRows, expected) => { + expect( + deriveSessionStatus({ + conversationResponding: responding, + tasks: [...taskRows], + }), + ).toBe(expected); + }); + + it('prioritizes active work over blocked settled work', () => { + expect( + deriveSessionStatus({ + conversationResponding: false, + tasks: [task({ state: 'failed' }), task({ state: 'active' })], + }), + ).toBe('active'); + }); +}); + +describe('session helpers', () => { + it('updates activity monotonically', async () => { + const session = await sessionFactory.create({ activityAt: 100 }); + createdSessionIds.push(session.id); + + await touchSessionActivity(db, session.id, 200); + const updated = await touchSessionActivity(db, session.id, 150); + + expect(updated.activityAt).toBe(200); + }); + + it('keeps Fast-only Sessions active while a conversation is responding', async () => { + const session = await sessionFactory.create({ cachedStatus: 'ready' }); + createdSessionIds.push(session.id); + + const active = await touchSessionActivity(db, session.id, 200, { + conversationResponding: true, + }); + const ready = await touchSessionActivity(db, session.id, 201, { + conversationResponding: false, + }); + + expect(active.cachedStatus).toBe('active'); + expect(ready.cachedStatus).toBe('ready'); + }); + + it('recomputes cached status from linked tasks while touching activity', async () => { + const session = await sessionFactory.create({ + activityAt: 100, + cachedStatus: 'ready', + }); + createdSessionIds.push(session.id); + const task = await taskFactory.create({ + state: 'active', + activityAt: 200, + }); + createdTaskIds.push(task.id); + await db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'direct_launch', + }); + + const updated = await touchSessionActivity(db, session.id, 200); + + expect(updated).toEqual( + expect.objectContaining({ activityAt: 200, cachedStatus: 'active' }), + ); + }); + + it('excludes soft-deleted tasks when recomputing cached status', async () => { + const session = await sessionFactory.create({ + activityAt: 100, + cachedStatus: 'blocked', + }); + createdSessionIds.push(session.id); + const task = await taskFactory.create({ + state: 'failed', + deletedAt: new Date(), + }); + createdTaskIds.push(task.id); + await db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'direct_launch', + }); + + const updated = await touchSessionActivity(db, session.id, 100); + + expect(updated.cachedStatus).toBe('ready'); + }); + + it('serializes concurrent status refreshes before reading linked tasks', async () => { + const session = await sessionFactory.create({ + activityAt: 100, + cachedStatus: 'active', + }); + createdSessionIds.push(session.id); + const firstTask = await taskFactory.create({ state: 'active' }); + const secondTask = await taskFactory.create({ state: 'active' }); + createdTaskIds.push(firstTask.id, secondTask.id); + await db.insert(sessionTasks).values([ + { + sessionId: session.id, + taskId: firstTask.id, + origin: 'direct_launch', + }, + { + sessionId: session.id, + taskId: secondTask.id, + origin: 'follow_up', + }, + ]); + + let releaseFirst!: () => void; + const firstCanCommit = new Promise((resolve) => { + releaseFirst = resolve; + }); + let firstRefreshed!: () => void; + const firstRefreshComplete = new Promise((resolve) => { + firstRefreshed = resolve; + }); + + const first = db.transaction(async (tx) => { + await tx + .update(tasks) + .set({ state: 'completed' }) + .where(eq(tasks.id, firstTask.id)); + await touchSessionActivity(tx, session.id, 200); + firstRefreshed(); + await firstCanCommit; + }); + await firstRefreshComplete; + + let secondUpdated!: () => void; + const secondTaskUpdated = new Promise((resolve) => { + secondUpdated = resolve; + }); + const second = db.transaction(async (tx) => { + await tx + .update(tasks) + .set({ state: 'completed' }) + .where(eq(tasks.id, secondTask.id)); + secondUpdated(); + await touchSessionActivity(tx, session.id, 300); + }); + await secondTaskUpdated; + await new Promise((resolve) => setTimeout(resolve, 50)); + releaseFirst(); + await Promise.all([first, second]); + + const [refreshed] = await db + .select({ cachedStatus: sessions.cachedStatus }) + .from(sessions) + .where(eq(sessions.id, session.id)); + expect(refreshed?.cachedStatus).toBe('ready'); + }); + + it('creates one canonical session and owner participant for a visible task', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const task = await taskFactory.create({ initiatorUserId: user.id }); + createdTaskIds.push(task.id); + + const first = await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id }), + ); + const second = await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id, existingTaskReused: true }), + ); + + expect(first).not.toBeNull(); + expect(second?.id).toBe(first?.id); + if (first) createdSessionIds.push(first.id); + + const links = await db + .select() + .from(sessionTasks) + .where(eq(sessionTasks.taskId, task.id)); + const participants = await db + .select() + .from(sessionParticipants) + .where(eq(sessionParticipants.sessionId, first!.id)); + + expect(links).toHaveLength(1); + expect(participants).toEqual([ + expect.objectContaining({ userId: user.id, role: 'owner' }), + ]); + }); + + it('does not create a session for a hidden task', async () => { + const task = await taskFactory.create({ visibility: 'hidden' }); + createdTaskIds.push(task.id); + + const result = await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id }), + ); + + expect(result).toBeNull(); + expect( + await db + .select() + .from(sessionTasks) + .where(eq(sessionTasks.taskId, task.id)), + ).toEqual([]); + }); + + it('retains a session when its owner user is deleted', async () => { + const user = await userFactory.create(); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: user.id, + }); + createdSessionIds.push(session.id); + + await db.delete(users).where(eq(users.id, user.id)); + + const [retained] = await db + .select() + .from(sessions) + .where(eq(sessions.id, session.id)); + expect(retained).toEqual( + expect.objectContaining({ ownerKind: 'user', ownerUserId: null }), + ); + }); + + it('attaches Fast-delegated tasks to the conversation session', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: `workspace-${crypto.randomUUID()}`, + conversationId: `conversation-${crypto.randomUUID()}`, + }) + .returning(); + createdConversationIds.push(conversation!.id); + + const firstTask = await taskFactory.create({ + initiatorUserId: user.id, + activityAt: 100, + }); + const secondTask = await taskFactory.create({ + initiatorUserId: user.id, + activityAt: 200, + }); + createdTaskIds.push(firstTask.id, secondTask.id); + + const first = await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: firstTask.id, + fastConversationId: conversation!.id, + origin: 'fast_delegation', + }), + ); + const second = await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: secondTask.id, + fastConversationId: conversation!.id, + origin: 'fast_delegation', + }), + ); + + expect(second?.id).toBe(first?.id); + expect(second?.activityAt).toBe(200); + if (first) createdSessionIds.push(first.id); + expect( + await db + .select() + .from(sessionTasks) + .where(eq(sessionTasks.sessionId, first!.id)), + ).toHaveLength(2); + }); + + it('creates one Session when a Fast conversation is created repeatedly', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: `workspace-${crypto.randomUUID()}`, + conversationId: `conversation-${crypto.randomUUID()}`, + }) + .returning(); + createdConversationIds.push(conversation!.id); + + const first = await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation!.id), + ); + const second = await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation!.id), + ); + createdSessionIds.push(first.id); + + expect(second.id).toBe(first.id); + expect( + await db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).toHaveLength(1); + }); + + it('never regresses a participant read cursor', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: user.id, + }); + createdSessionIds.push(session.id); + + await advanceSessionReadCursor(db, { + sessionId: session.id, + userId: user.id, + eventAt: 200, + eventId: 'event-b', + }); + const current = await advanceSessionReadCursor(db, { + sessionId: session.id, + userId: user.id, + eventAt: 100, + eventId: 'event-a', + }); + + expect(current.lastReadEventAt).toBe(200); + expect(current.lastReadEventId).toBe('event-b'); + }); + + it('advances participant notification cursors monotonically', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: user.id, + }); + createdSessionIds.push(session.id); + await db.insert(sessionParticipants).values({ + sessionId: session.id, + userId: user.id, + role: 'owner', + }); + + await advanceSessionNotifiedCursor(db, { + sessionId: session.id, + eventAt: 200, + eventId: 'event-b', + }); + await advanceSessionNotifiedCursor(db, { + sessionId: session.id, + eventAt: 100, + eventId: 'event-a', + }); + + const [participant] = await db + .select() + .from(sessionParticipants) + .where(eq(sessionParticipants.sessionId, session.id)); + expect(participant?.lastNotifiedEventAt).toBe(200); + expect(participant?.lastNotifiedEventId).toBe('event-b'); + }); + + it('stamps new task usage with the owning Session', async () => { + const task = await taskFactory.create(); + createdTaskIds.push(task.id); + const session = await sessionFactory.create(); + createdSessionIds.push(session.id); + await db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'direct_launch', + }); + + await recordLlmUsage({ + taskId: task.id, + eventKey: `session-usage:${crypto.randomUUID()}`, + inputTokens: 10, + outputTokens: 5, + }); + + const [usage] = await db + .select({ sessionId: llmUsageEvents.sessionId }) + .from(llmUsageEvents) + .where(eq(llmUsageEvents.taskId, task.id)); + expect(usage?.sessionId).toBe(session.id); + }); +}); diff --git a/packages/db/src/lib/llm-usage.ts b/packages/db/src/lib/llm-usage.ts index 0b5c7e70b..77bff508b 100644 --- a/packages/db/src/lib/llm-usage.ts +++ b/packages/db/src/lib/llm-usage.ts @@ -1,7 +1,9 @@ +import { eq } from 'drizzle-orm'; + import type { LlmUsageCostSource } from '@roomote/types'; import { db } from '../db'; -import { llmUsageEvents } from '../schema'; +import { llmUsageEvents, sessions, sessionTasks } from '../schema'; export interface RecordLlmUsageInput { source?: string; @@ -11,6 +13,7 @@ export interface RecordLlmUsageInput { runId?: number | null; userId?: string | null; environmentId?: string | null; + fastConversationId?: string | null; harnessSessionId?: string | null; messageId?: string | null; providerId?: string | null; @@ -100,6 +103,19 @@ export async function recordLlmUsage( : clampOptionalInteger(input.contextTokens); const costSource = input.costSource ?? 'missing'; const agent = normalizeAgent(input.agent); + const sessionId = input.fastConversationId + ? await db.query.sessions.findFirst({ + where: eq(sessions.fastConversationId, input.fastConversationId), + columns: { id: true }, + }) + : input.taskId + ? await db + .select({ id: sessionTasks.sessionId }) + .from(sessionTasks) + .where(eq(sessionTasks.taskId, input.taskId)) + .limit(1) + .then((rows) => rows[0]) + : null; const values = { source: input.source ?? 'roomote', @@ -108,6 +124,7 @@ export async function recordLlmUsage( runId: input.runId ?? null, userId: input.userId ?? null, environmentId: input.environmentId ?? null, + sessionId: sessionId?.id ?? null, eventKey: input.eventKey ?? null, harnessSessionId: input.harnessSessionId ?? null, messageId: input.messageId ?? null, diff --git a/packages/db/src/lib/sessions.ts b/packages/db/src/lib/sessions.ts new file mode 100644 index 000000000..b53c51461 --- /dev/null +++ b/packages/db/src/lib/sessions.ts @@ -0,0 +1,466 @@ +import { and, desc, eq, isNull, or, sql } from 'drizzle-orm'; + +import type { TaskGoalStatus, TaskState } from '@roomote/types'; + +import type { DatabaseOrTransaction } from '../db'; +import { + sessionParticipants, + sessions, + sessionTasks, + fastAgentConversations, + taskRuns, + tasks, + type SessionStatus, + type SessionTaskOrigin, +} from '../schema'; +import type { Session } from '../types'; + +import { runInTransactionIfAvailable } from './transaction-utils'; + +export type SessionStatusInput = { + conversationResponding: boolean; + tasks: Array<{ + state: TaskState; + taskPhase: string | null; + goalStatus: TaskGoalStatus | null; + }>; +}; + +export function deriveSessionStatus(input: SessionStatusInput): SessionStatus { + if ( + input.tasks.some( + (task) => + task.state === 'active' && task.taskPhase === 'waiting_for_user_input', + ) + ) { + return 'needs_input'; + } + + if ( + input.conversationResponding || + input.tasks.some((task) => task.state === 'active') + ) { + return 'active'; + } + + if ( + input.tasks.some( + (task) => + task.state === 'failed' || + task.goalStatus === 'blocked' || + task.goalStatus === 'budget_limited', + ) + ) { + return 'blocked'; + } + + return 'ready'; +} + +export async function touchSessionActivity( + dbOrTx: DatabaseOrTransaction, + sessionId: string, + at: number, + options: { + conversationResponding?: boolean; + recomputeStatus?: boolean; + } = {}, +): Promise { + return runInTransactionIfAvailable(dbOrTx, async (tx) => { + const [lockedSession] = await tx + .select({ id: sessions.id }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .for('update'); + + if (!lockedSession) { + throw new Error(`Session ${sessionId} does not exist.`); + } + + return refreshLockedSession(tx, sessionId, at, options); + }); +} + +async function refreshLockedSession( + tx: DatabaseOrTransaction, + sessionId: string, + at: number, + options: { conversationResponding?: boolean; recomputeStatus?: boolean }, +): Promise { + const linkedTasks = await tx + .selectDistinctOn([tasks.id], { + state: tasks.state, + taskPhase: taskRuns.taskPhase, + goalStatus: tasks.goalStatus, + }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .leftJoin(taskRuns, eq(taskRuns.taskId, tasks.id)) + .where(and(eq(sessionTasks.sessionId, sessionId), isNull(tasks.deletedAt))) + .orderBy(tasks.id, desc(taskRuns.id)); + + const [updated] = await tx + .update(sessions) + .set({ + activityAt: sql`GREATEST(${sessions.activityAt}, ${at})`, + ...(options.recomputeStatus === false + ? {} + : { + cachedStatus: deriveSessionStatus({ + conversationResponding: options.conversationResponding ?? false, + tasks: linkedTasks, + }), + }), + updatedAt: new Date(), + }) + .where(eq(sessions.id, sessionId)) + .returning(); + + if (!updated) throw new Error(`Session ${sessionId} does not exist.`); + + return updated; +} + +export type EnsureSessionForTaskInput = { + taskId: string; + fastConversationId?: string | null; + origin?: SessionTaskOrigin; + existingTaskReused?: boolean; +}; + +export async function ensureSessionForFastConversation( + tx: DatabaseOrTransaction, + fastConversationId: string, +): Promise { + const [conversation] = await tx + .select({ + id: fastAgentConversations.id, + userId: fastAgentConversations.userId, + surface: fastAgentConversations.surface, + title: fastAgentConversations.title, + updatedAt: fastAgentConversations.updatedAt, + }) + .from(fastAgentConversations) + .where(eq(fastAgentConversations.id, fastConversationId)) + .for('update'); + + if (!conversation) { + throw new Error(`Fast conversation ${fastConversationId} does not exist.`); + } + + const existing = await getSessionForFastConversation(tx, conversation.id); + if (existing) { + return existing; + } + + const activityAt = Math.floor(conversation.updatedAt.getTime() / 1000); + const [inserted] = await tx + .insert(sessions) + .values({ + title: conversation.title?.trim() || 'New session', + ownerKind: 'user', + ownerUserId: conversation.userId, + sourceSurface: conversation.surface, + sourceTrigger: + conversation.surface === 'automation' ? 'schedule' : 'message', + fastConversationId: conversation.id, + visibility: 'visible', + activityAt, + cachedStatus: 'ready', + }) + .onConflictDoNothing() + .returning(); + + const session = + inserted ?? (await getSessionForFastConversation(tx, conversation.id)); + if (!session) { + throw new Error( + `Failed to create a Session for Fast conversation ${conversation.id}.`, + ); + } + + await tx + .insert(sessionParticipants) + .values({ + sessionId: session.id, + userId: conversation.userId, + role: 'owner', + }) + .onConflictDoNothing(); + + return session; +} + +/** + * Ensures a visible task has one canonical Session inside the caller's + * transaction. The tables are additive and ignored by N-1 application code. + */ +export async function ensureSessionForTask( + tx: DatabaseOrTransaction, + input: EnsureSessionForTaskInput, +): Promise { + const [task] = await tx + .select({ + id: tasks.id, + title: tasks.title, + state: tasks.state, + goalStatus: tasks.goalStatus, + initiatorKind: tasks.initiatorKind, + initiatorUserId: tasks.initiatorUserId, + initiatorAutomation: tasks.initiatorAutomation, + surface: tasks.surface, + trigger: tasks.trigger, + visibility: tasks.visibility, + activityAt: tasks.activityAt, + }) + .from(tasks) + .where(eq(tasks.id, input.taskId)) + .for('update'); + + if (!task) { + throw new Error(`Task ${input.taskId} does not exist.`); + } + + if (task.visibility !== 'visible') { + return null; + } + + const existing = await getSessionForTask(tx, task.id); + if (existing) { + return existing; + } + + let session = input.fastConversationId + ? await getSessionForFastConversation(tx, input.fastConversationId) + : null; + let createdCandidate = false; + + if (!session) { + const owner = + task.initiatorKind === 'user' && task.initiatorUserId + ? { + ownerKind: 'user' as const, + ownerUserId: task.initiatorUserId, + ownerAutomation: null, + } + : task.initiatorKind === 'automation' && task.initiatorAutomation + ? { + ownerKind: 'automation' as const, + ownerUserId: null, + ownerAutomation: task.initiatorAutomation, + } + : { + ownerKind: 'system' as const, + ownerUserId: null, + ownerAutomation: null, + }; + + const [inserted] = await tx + .insert(sessions) + .values({ + title: task.title, + ...owner, + sourceSurface: task.surface, + sourceTrigger: task.trigger, + fastConversationId: input.fastConversationId ?? null, + visibility: task.visibility, + activityAt: task.activityAt, + cachedStatus: deriveSessionStatus({ + conversationResponding: false, + tasks: [ + { + state: task.state, + taskPhase: null, + goalStatus: task.goalStatus, + }, + ], + }), + }) + .onConflictDoNothing() + .returning(); + + session = + inserted ?? + (input.fastConversationId + ? await getSessionForFastConversation(tx, input.fastConversationId) + : null); + createdCandidate = inserted !== undefined; + } + + if (!session) { + throw new Error(`Failed to create a Session for task ${task.id}.`); + } + + const [attached] = await tx + .insert(sessionTasks) + .values({ + sessionId: session.id, + taskId: task.id, + origin: input.origin ?? 'direct_launch', + }) + .onConflictDoNothing({ target: sessionTasks.taskId }) + .returning({ sessionId: sessionTasks.sessionId }); + + if (!attached) { + const canonical = await getSessionForTask(tx, task.id); + if (!canonical) { + throw new Error(`Failed to attach task ${task.id} to a Session.`); + } + + if (createdCandidate && canonical.id !== session.id) { + await tx.delete(sessions).where(eq(sessions.id, session.id)); + } + + return touchSessionActivity(tx, canonical.id, task.activityAt); + } + + if (session.ownerUserId) { + await tx + .insert(sessionParticipants) + .values({ + sessionId: session.id, + userId: session.ownerUserId, + role: 'owner', + }) + .onConflictDoNothing(); + } + + return touchSessionActivity(tx, session.id, task.activityAt); +} + +export async function getSessionForTask( + tx: DatabaseOrTransaction, + taskId: string, +): Promise { + const [session] = await tx + .select({ session: sessions }) + .from(sessionTasks) + .innerJoin(sessions, eq(sessions.id, sessionTasks.sessionId)) + .where(eq(sessionTasks.taskId, taskId)) + .limit(1); + + return session?.session ?? null; +} + +export async function getSessionForFastConversation( + tx: DatabaseOrTransaction, + fastConversationId: string, +): Promise { + const [session] = await tx + .select() + .from(sessions) + .where( + and( + eq(sessions.fastConversationId, fastConversationId), + eq(sessions.visibility, 'visible'), + ), + ) + .limit(1); + + return session ?? null; +} + +export async function touchSessionForTask( + tx: DatabaseOrTransaction, + taskId: string, + at: number, +): Promise { + const session = await getSessionForTask(tx, taskId); + return session ? touchSessionActivity(tx, session.id, at) : null; +} + +export async function advanceSessionReadCursor( + dbOrTx: DatabaseOrTransaction, + input: { + sessionId: string; + userId: string; + eventAt: number; + eventId: string; + }, +) { + return runInTransactionIfAvailable(dbOrTx, async (tx) => { + const [lockedSession] = await tx + .select({ id: sessions.id }) + .from(sessions) + .where(eq(sessions.id, input.sessionId)) + .for('update'); + if (!lockedSession) { + throw new Error(`Session ${input.sessionId} does not exist.`); + } + + const [participant] = await tx + .insert(sessionParticipants) + .values({ + sessionId: input.sessionId, + userId: input.userId, + role: 'member', + lastReadEventAt: input.eventAt, + lastReadEventId: input.eventId, + }) + .onConflictDoUpdate({ + target: [sessionParticipants.sessionId, sessionParticipants.userId], + set: { + lastReadEventAt: input.eventAt, + lastReadEventId: input.eventId, + updatedAt: new Date(), + }, + setWhere: or( + sql`${sessionParticipants.lastReadEventAt} IS NULL`, + sql`${sessionParticipants.lastReadEventAt} < ${input.eventAt}`, + and( + eq(sessionParticipants.lastReadEventAt, input.eventAt), + or( + sql`${sessionParticipants.lastReadEventId} IS NULL`, + sql`${sessionParticipants.lastReadEventId} < ${input.eventId}`, + ), + ), + ), + }) + .returning(); + + if (participant) return participant; + + const [current] = await tx + .select() + .from(sessionParticipants) + .where( + and( + eq(sessionParticipants.sessionId, input.sessionId), + eq(sessionParticipants.userId, input.userId), + ), + ); + if (!current) { + throw new Error('Failed to advance Session read cursor.'); + } + return current; + }); +} + +export async function advanceSessionNotifiedCursor( + tx: DatabaseOrTransaction, + input: { sessionId: string; eventAt: number; eventId: string }, +): Promise { + await tx + .update(sessionParticipants) + .set({ + lastNotifiedEventAt: input.eventAt, + lastNotifiedEventId: input.eventId, + updatedAt: new Date(), + }) + .where( + and( + eq(sessionParticipants.sessionId, input.sessionId), + or( + sql`${sessionParticipants.lastNotifiedEventAt} IS NULL`, + sql`${sessionParticipants.lastNotifiedEventAt} < ${input.eventAt}`, + and( + eq(sessionParticipants.lastNotifiedEventAt, input.eventAt), + or( + sql`${sessionParticipants.lastNotifiedEventId} IS NULL`, + sql`${sessionParticipants.lastNotifiedEventId} < ${input.eventId}`, + ), + ), + ), + ), + ); +} diff --git a/packages/db/src/lib/sync-task-state.ts b/packages/db/src/lib/sync-task-state.ts index 87cc00160..6d0d92a9a 100644 --- a/packages/db/src/lib/sync-task-state.ts +++ b/packages/db/src/lib/sync-task-state.ts @@ -3,6 +3,7 @@ import { RunStatus, type TaskState } from '@roomote/types'; import { type DatabaseOrTransaction } from '../db'; import { taskRuns, tasks } from '../schema'; +import { touchSessionForTask } from './sessions'; /** * Run statuses that keep the owning task 'active': the sandbox is still (or @@ -134,4 +135,6 @@ export async function syncTaskStateFromRuns( .update(tasks) .set({ state: nextState, updatedAt: new Date() }) .where(and(eq(tasks.id, taskId), ne(tasks.state, nextState))); + + await touchSessionForTask(tx, taskId, Math.floor(Date.now() / 1000)); } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ec14663c2..6273fcd46 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -150,6 +150,8 @@ export const users = pgTable( export const userRelations = relations(users, ({ many }) => ({ tasks: many(tasks, { relationName: 'taskInitiatorUser' }), taskPins: many(taskPins), + ownedSessions: many(sessions, { relationName: 'sessionOwnerUser' }), + sessionParticipants: many(sessionParticipants), slackFastIntegrationCalls: many(slackFastIntegrationCalls), workItems: many(workItems), setupQualificationBlocks: many(setupQualificationBlocks), @@ -840,6 +842,7 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({ relationName: 'taskCommitAuthorUser', }), taskPins: many(taskPins), + sessionTasks: many(sessionTasks), runs: many(taskRuns), inferenceUsageEvents: many(llmUsageEvents), workItemsAsSource: many(workItems, { @@ -1792,6 +1795,9 @@ export const llmUsageEvents = pgTable( environmentId: uuid('environment_id').references(() => environments.id, { onDelete: 'set null', }), + sessionId: uuid('session_id').references(() => sessions.id, { + onDelete: 'set null', + }), // Non-task producers use eventKey for idempotency. Task harness events use // the session/message pair below because a message may be retried with // progressively richer usage data. @@ -1859,6 +1865,7 @@ export const llmUsageEvents = pgTable( index('task_inference_usage_events_environment_id_idx').on( table.environmentId, ), + index('task_inference_usage_events_session_id_idx').on(table.sessionId), index('task_inference_usage_events_provider_model_idx').on( table.providerId, table.modelId, @@ -1884,6 +1891,10 @@ export const llmUsageEventsRelations = relations(llmUsageEvents, ({ one }) => ({ fields: [llmUsageEvents.environmentId], references: [environments.id], }), + session: one(sessions, { + fields: [llmUsageEvents.sessionId], + references: [sessions.id], + }), })); export const taskSlackReplyDetails = pgTable( @@ -3232,6 +3243,7 @@ export const fastAgentConversationsRelations = relations( messages: many(fastAgentMessages), providerMessages: many(fastAgentProviderMessages), prFeedbackDeliveries: many(fastAgentPrFeedbackDeliveries), + session: one(sessions), }), ); @@ -3505,10 +3517,278 @@ export const automations = pgTable('automations', { export const automationsRelations = relations(automations, ({ many }) => ({ tasks: many(tasks), + sessions: many(sessions), workItems: many(workItems), trackedMessages: many(trackedMessages), })); +export type SessionOwnerKind = 'user' | 'automation' | 'system'; +export type SessionSourceSurface = TaskSurface | FastAgentSurface; +export type SessionStatus = 'active' | 'needs_input' | 'blocked' | 'ready'; +export type SessionTaskOrigin = + | 'direct_launch' + | 'fast_delegation' + | 'backfill' + | 'follow_up'; +export type SessionParticipantRole = 'owner' | 'member'; +export type SessionBackfillPhase = + | 'fast_conversations' + | 'fast_tasks' + | 'tasks' + | 'participants'; + +/** + * sessions + * + * Additive Session storage is intentionally separate from tasks and Fast + * conversations so the previous release remains safe against this schema for + * N-1 rollback. Existing operational records remain canonical. + */ +export const sessions = pgTable( + 'sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + ownerKind: text('owner_kind').notNull().$type(), + ownerUserId: text('owner_user_id').references(() => users.id, { + onDelete: 'set null', + }), + ownerAutomation: text('owner_automation') + .$type() + .references(() => automations.key, { onDelete: 'set null' }), + sourceSurface: text('source_surface') + .notNull() + .$type(), + sourceTrigger: text('source_trigger').notNull().$type(), + fastConversationId: uuid('fast_conversation_id').references( + () => fastAgentConversations.id, + { onDelete: 'set null' }, + ), + visibility: text('visibility') + .notNull() + .default('visible') + .$type(), + activityAt: bigint('activity_at', { mode: 'number' }).notNull(), + cachedStatus: text('cached_status').$type(), + archivedAt: timestamp('archived_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('sessions_visibility_activity_at_idx').on( + table.visibility, + table.activityAt.desc(), + table.id.desc(), + ), + index('sessions_owner_user_id_idx').on(table.ownerUserId), + uniqueIndex('sessions_fast_conversation_id_unique') + .on(table.fastConversationId) + .where(sql`${table.fastConversationId} IS NOT NULL`), + check( + 'sessions_owner_shape_check', + // Owner FKs use ON DELETE SET NULL so retained Sessions can outlive + // deleted users and automation definitions. The shape still prevents a + // value from being stored in the wrong owner column. + sql`(${table.ownerKind} = 'user' AND ${table.ownerAutomation} IS NULL) OR (${table.ownerKind} = 'automation' AND ${table.ownerUserId} IS NULL) OR (${table.ownerKind} = 'system' AND ${table.ownerUserId} IS NULL AND ${table.ownerAutomation} IS NULL)`, + ), + check( + 'sessions_owner_kind_check', + sql`${table.ownerKind} in ('user', 'automation', 'system')`, + ), + check( + 'sessions_source_surface_check', + sql`${table.sourceSurface} in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system', 'automation')`, + ), + check( + 'sessions_source_trigger_check', + sql`${table.sourceTrigger} in ('message', 'webhook', 'schedule', 'manual')`, + ), + check( + 'sessions_visibility_check', + sql`${table.visibility} in ('visible', 'hidden')`, + ), + check( + 'sessions_cached_status_check', + sql`${table.cachedStatus} IS NULL OR ${table.cachedStatus} in ('active', 'needs_input', 'blocked', 'ready')`, + ), + ], +); + +/** Additive task linkage retained independently for N-1 rollback safety. */ +export const sessionTasks = pgTable( + 'session_tasks', + { + sessionId: uuid('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + taskId: text('task_id') + .notNull() + .references(() => tasks.id, { onDelete: 'cascade' }), + attachedAt: timestamp('attached_at').notNull().defaultNow(), + origin: text('origin').notNull().$type(), + }, + (table) => [ + primaryKey({ + name: 'session_tasks_session_id_task_id_pk', + columns: [table.sessionId, table.taskId], + }), + uniqueIndex('session_tasks_task_id_unique').on(table.taskId), + index('session_tasks_session_attached_at_idx').on( + table.sessionId, + table.attachedAt.desc(), + ), + check( + 'session_tasks_origin_check', + sql`${table.origin} in ('direct_launch', 'fast_delegation', 'backfill', 'follow_up')`, + ), + ], +); + +/** Additive read-state storage retained independently for N-1 rollback safety. */ +export const sessionParticipants = pgTable( + 'session_participants', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: uuid('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => users.id, { + onDelete: 'cascade', + }), + role: text('role') + .notNull() + .default('member') + .$type(), + lastReadEventAt: bigint('last_read_event_at', { mode: 'number' }), + lastReadEventId: text('last_read_event_id'), + lastNotifiedEventAt: bigint('last_notified_event_at', { mode: 'number' }), + lastNotifiedEventId: text('last_notified_event_id'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('session_participants_session_user_unique').on( + table.sessionId, + table.userId, + ), + index('session_participants_user_id_idx').on(table.userId), + check( + 'session_participants_role_check', + sql`${table.role} in ('owner', 'member')`, + ), + ], +); + +/** User-scoped Session pins mirror task pins without changing task storage. */ +export const sessionPins = pgTable( + 'session_pins', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: uuid('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('session_pins_user_session_unique').on( + table.userId, + table.sessionId, + ), + index('session_pins_user_updated_at_idx').on(table.userId, table.updatedAt), + index('session_pins_session_id_idx').on(table.sessionId), + ], +); + +/** Durable bounded-backfill position retained independently for N-1 safety. */ +export const sessionBackfillState = pgTable( + 'session_backfill_state', + { + key: text('key').primaryKey(), + phase: text('phase') + .notNull() + .default('fast_conversations') + .$type(), + cursorCreatedAt: timestamp('cursor_created_at'), + cursorId: text('cursor_id'), + completedAt: timestamp('completed_at'), + lastRunAt: timestamp('last_run_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + check( + 'session_backfill_state_phase_check', + sql`${table.phase} in ('fast_conversations', 'fast_tasks', 'tasks', 'participants')`, + ), + check( + 'session_backfill_state_cursor_shape_check', + sql`(${table.cursorCreatedAt} IS NULL) = (${table.cursorId} IS NULL)`, + ), + ], +); + +export const sessionsRelations = relations(sessions, ({ one, many }) => ({ + ownerUser: one(users, { + fields: [sessions.ownerUserId], + references: [users.id], + relationName: 'sessionOwnerUser', + }), + ownerAutomationRow: one(automations, { + fields: [sessions.ownerAutomation], + references: [automations.key], + }), + fastConversation: one(fastAgentConversations, { + fields: [sessions.fastConversationId], + references: [fastAgentConversations.id], + }), + tasks: many(sessionTasks), + participants: many(sessionParticipants), + pins: many(sessionPins), + usageEvents: many(llmUsageEvents), +})); + +export const sessionTasksRelations = relations(sessionTasks, ({ one }) => ({ + session: one(sessions, { + fields: [sessionTasks.sessionId], + references: [sessions.id], + }), + task: one(tasks, { + fields: [sessionTasks.taskId], + references: [tasks.id], + }), +})); + +export const sessionParticipantsRelations = relations( + sessionParticipants, + ({ one }) => ({ + session: one(sessions, { + fields: [sessionParticipants.sessionId], + references: [sessions.id], + }), + user: one(users, { + fields: [sessionParticipants.userId], + references: [users.id], + }), + }), +); + +export const sessionPinsRelations = relations(sessionPins, ({ one }) => ({ + session: one(sessions, { + fields: [sessionPins.sessionId], + references: [sessions.id], + }), + user: one(users, { + fields: [sessionPins.userId], + references: [users.id], + }), +})); + /** * custom_automations * diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 119e6a0c5..54c215d8b 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -50,6 +50,7 @@ export * from './lib/task-suggestion-content-hash'; export * from './lib/work-item-claims'; export * from './lib/task-start-parallel-counts'; export * from './lib/tasks'; +export * from './lib/sessions'; export * from './lib/task-goals'; export * from './lib/source-control-provider'; export * from './lib/sync-task-state'; @@ -119,6 +120,15 @@ export { tasksRelations, taskPins, taskPinsRelations, + sessions, + sessionsRelations, + sessionTasks, + sessionTasksRelations, + sessionParticipants, + sessionParticipantsRelations, + sessionPins, + sessionPinsRelations, + sessionBackfillState, taskArtifacts, taskArtifactsRelations, taskPullRequests, @@ -242,5 +252,11 @@ export type { SuggestionType, ManagerMcpSetupNotificationReason, EnvironmentConfigVersionSource, + SessionOwnerKind, + SessionSourceSurface, + SessionStatus, + SessionTaskOrigin, + SessionParticipantRole, + SessionBackfillPhase, } from './schema'; export type { AutomationWorkItemDisposition } from '@roomote/types'; diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 653e24c41..877668b64 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -23,6 +23,11 @@ import type { deploymentSettings, tasks, taskPins, + sessions, + sessionTasks, + sessionParticipants, + sessionPins, + sessionBackfillState, taskPullRequests, taskRuns, taskRunEvents, @@ -100,6 +105,31 @@ export type TaskPin = typeof taskPins.$inferSelect; export type CreateTaskPin = Omit; +/** + * sessions + */ + +export type Session = typeof sessions.$inferSelect; + +export type CreateSession = Omit; + +export type SessionTask = typeof sessionTasks.$inferSelect; + +export type CreateSessionTask = Omit< + typeof sessionTasks.$inferInsert, + 'attachedAt' +>; + +export type SessionParticipant = typeof sessionParticipants.$inferSelect; + +export type CreateSessionParticipant = Omit< + typeof sessionParticipants.$inferInsert, + Generated +>; + +export type SessionBackfillState = typeof sessionBackfillState.$inferSelect; +export type SessionPin = typeof sessionPins.$inferSelect; + /** * taskPullRequests */ diff --git a/packages/feature-flags/src/__tests__/config.test.ts b/packages/feature-flags/src/__tests__/config.test.ts index 0a0e08a52..ad1ebcacd 100644 --- a/packages/feature-flags/src/__tests__/config.test.ts +++ b/packages/feature-flags/src/__tests__/config.test.ts @@ -4,8 +4,18 @@ import { FEATURE_FLAG_CONFIG } from '../config'; import { FeatureFlag } from '../types'; describe('feature flags', () => { - it('defines zero recognized flags and zero config entries', () => { - expect(FeatureFlag).toEqual({}); - expect(FEATURE_FLAG_CONFIG).toEqual({}); + it('defines the independently reversible Sessions rollout flags', () => { + expect(FeatureFlag).toEqual({ + SessionsData: 'sessions_data', + SessionsUi: 'sessions_ui', + SessionsComms: 'sessions_comms', + }); + expect(FEATURE_FLAG_CONFIG).toEqual( + expect.objectContaining({ + sessions_data: expect.objectContaining({ defaultValue: false }), + sessions_ui: expect.objectContaining({ defaultValue: false }), + sessions_comms: expect.objectContaining({ defaultValue: false }), + }), + ); }); }); diff --git a/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts b/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts index f4b67ed32..324d148e6 100644 --- a/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts +++ b/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts @@ -23,7 +23,7 @@ describe('generic feature flag evaluation', () => { }); }); - it('evaluates all flags to an empty object even with stale metadata', () => { + it('ignores stale metadata and keeps Sessions flags disabled by default', () => { expect( evaluateFeatureFlagsFromMetadata({ slack_eval_launcher: true, @@ -33,6 +33,24 @@ describe('generic feature flag evaluation', () => { background_subagents: true, opencode_code_mode: true, }), - ).toEqual({}); + ).toEqual({ + sessions_data: false, + sessions_ui: false, + sessions_comms: false, + }); + }); + + it('evaluates each Sessions rollout flag independently', () => { + expect( + evaluateFeatureFlagsFromMetadata({ + sessions_data: true, + sessions_ui: 'true', + sessions_comms: false, + }), + ).toEqual({ + sessions_data: true, + sessions_ui: true, + sessions_comms: false, + }); }); }); diff --git a/packages/feature-flags/src/config.ts b/packages/feature-flags/src/config.ts index 107bb2cd6..9c94f5f4d 100644 --- a/packages/feature-flags/src/config.ts +++ b/packages/feature-flags/src/config.ts @@ -1,6 +1,25 @@ import type { FeatureFlagConfigMap, MetadataBooleanDescriptor } from './types'; -export const FEATURE_FLAG_CONFIG: FeatureFlagConfigMap = {}; +export const FEATURE_FLAG_CONFIG: FeatureFlagConfigMap = { + sessions_data: { + defaultValue: false, + metadataKey: 'sessions_data', + description: 'Create and reconcile unified Session records', + group: 'Sessions', + }, + sessions_ui: { + defaultValue: false, + metadataKey: 'sessions_ui', + description: 'Use Sessions as the primary dashboard navigation unit', + group: 'Sessions', + }, + sessions_comms: { + defaultValue: false, + metadataKey: 'sessions_comms', + description: 'Use Session-aware communication wording and links', + group: 'Sessions', + }, +}; /** * Non-feature-flag boolean deployment metadata that is still actively read in diff --git a/packages/feature-flags/src/evaluator.ts b/packages/feature-flags/src/evaluator.ts index 919641c6f..fa4fb6ac9 100644 --- a/packages/feature-flags/src/evaluator.ts +++ b/packages/feature-flags/src/evaluator.ts @@ -13,6 +13,7 @@ import { normalizeMetadataRecord, } from './index'; import { MetadataCache } from './cache'; +import { invalidateDeploymentFeatureFlagCache } from './server/deployment'; import type { FeatureFlag, FeatureFlagContext, @@ -72,6 +73,7 @@ export class FeatureFlagEvaluator { } async invalidateDeploymentCache(): Promise { + invalidateDeploymentFeatureFlagCache(); await this.cache.invalidate('deployment', 'default'); } } @@ -85,4 +87,5 @@ export function getFeatureFlagEvaluator(redis: Redis): FeatureFlagEvaluator { export function resetFeatureFlagEvaluatorForTests(): void { evaluatorInstance = null; + invalidateDeploymentFeatureFlagCache(); } diff --git a/packages/feature-flags/src/server/deployment.test.ts b/packages/feature-flags/src/server/deployment.test.ts new file mode 100644 index 000000000..c0b15e8d3 --- /dev/null +++ b/packages/feature-flags/src/server/deployment.test.ts @@ -0,0 +1,85 @@ +const { findDeploymentSettings } = vi.hoisted(() => ({ + findDeploymentSettings: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + deploymentSettings: { findFirst: findDeploymentSettings }, + }, + }, + deploymentSettings: { id: 'id' }, + eq: vi.fn(), +})); + +describe('evaluateDeploymentFeatureFlag', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + findDeploymentSettings.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('reuses deployment metadata until the bounded cache expires', async () => { + findDeploymentSettings + .mockResolvedValueOnce({ metadata: { sessions_data: true } }) + .mockResolvedValueOnce({ metadata: { sessions_data: false } }); + + const { evaluateDeploymentFeatureFlag } = await import('./deployment'); + + await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + true, + ); + await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + true, + ); + expect(findDeploymentSettings).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(30_001); + + await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + false, + ); + expect(findDeploymentSettings).toHaveBeenCalledTimes(2); + }); + + it('coalesces concurrent metadata reads', async () => { + findDeploymentSettings.mockResolvedValue({ + metadata: { sessions_data: true, sessions_comms: true }, + }); + + const { evaluateDeploymentFeatureFlag } = await import('./deployment'); + + await expect( + Promise.all([ + evaluateDeploymentFeatureFlag('sessions_data'), + evaluateDeploymentFeatureFlag('sessions_comms'), + ]), + ).resolves.toEqual([true, true]); + expect(findDeploymentSettings).toHaveBeenCalledTimes(1); + }); + + it('refreshes immediately after explicit invalidation', async () => { + findDeploymentSettings + .mockResolvedValueOnce({ metadata: { sessions_data: false } }) + .mockResolvedValueOnce({ metadata: { sessions_data: true } }); + + const { + evaluateDeploymentFeatureFlag, + invalidateDeploymentFeatureFlagCache, + } = await import('./deployment'); + + await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + false, + ); + invalidateDeploymentFeatureFlagCache(); + await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + true, + ); + + expect(findDeploymentSettings).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/feature-flags/src/server/deployment.ts b/packages/feature-flags/src/server/deployment.ts new file mode 100644 index 000000000..caf3560d5 --- /dev/null +++ b/packages/feature-flags/src/server/deployment.ts @@ -0,0 +1,66 @@ +import { db, deploymentSettings, eq } from '@roomote/db/server'; + +import { evaluateFeatureFlagFromMetadata } from '../index'; +import type { FeatureFlag } from '../types'; + +const DEFAULT_DEPLOYMENT_ID = 'default'; +const DEPLOYMENT_METADATA_CACHE_TTL_MS = 30_000; + +let cachedDeploymentMetadata: { value: unknown; expiresAt: number } | null = + null; +let pendingDeploymentMetadata: Promise | null = null; +let cacheGeneration = 0; + +export function invalidateDeploymentFeatureFlagCache(): void { + cachedDeploymentMetadata = null; + pendingDeploymentMetadata = null; + cacheGeneration += 1; +} + +async function getDeploymentMetadata(): Promise { + if ( + cachedDeploymentMetadata && + cachedDeploymentMetadata.expiresAt > Date.now() + ) { + return cachedDeploymentMetadata.value; + } + + if (pendingDeploymentMetadata) { + return pendingDeploymentMetadata; + } + + const generation = cacheGeneration; + const request = db.query.deploymentSettings + .findFirst({ + where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), + columns: { metadata: true }, + }) + .then((deployment) => { + const value = deployment?.metadata; + if (cacheGeneration === generation) { + cachedDeploymentMetadata = { + value, + expiresAt: Date.now() + DEPLOYMENT_METADATA_CACHE_TTL_MS, + }; + } + return value; + }) + .finally(() => { + if (pendingDeploymentMetadata === request) { + pendingDeploymentMetadata = null; + } + }); + pendingDeploymentMetadata = request; + + return request; +} + +/** + * Evaluates a deployment-wide flag without requiring Redis. Runtime write + * paths use this when cache availability must not gate task or Session writes. + */ +export async function evaluateDeploymentFeatureFlag( + flag: FeatureFlag, +): Promise { + return evaluateFeatureFlagFromMetadata(flag, await getDeploymentMetadata()); +} diff --git a/packages/feature-flags/src/server/index.ts b/packages/feature-flags/src/server/index.ts index d17f03f23..753eb31f1 100644 --- a/packages/feature-flags/src/server/index.ts +++ b/packages/feature-flags/src/server/index.ts @@ -4,4 +4,8 @@ export { resetFeatureFlagEvaluatorForTests, } from '../evaluator'; export { MetadataCache } from '../cache'; +export { + evaluateDeploymentFeatureFlag, + invalidateDeploymentFeatureFlagCache, +} from './deployment'; export * from '../index'; diff --git a/packages/feature-flags/src/types.ts b/packages/feature-flags/src/types.ts index eb8b981a9..649b2f060 100644 --- a/packages/feature-flags/src/types.ts +++ b/packages/feature-flags/src/types.ts @@ -2,7 +2,11 @@ * Feature flag types and configuration */ -export const FeatureFlag = {} as const; +export const FeatureFlag = { + SessionsData: 'sessions_data', + SessionsUi: 'sessions_ui', + SessionsComms: 'sessions_comms', +} as const; export type FeatureFlag = (typeof FeatureFlag)[keyof typeof FeatureFlag]; @@ -37,7 +41,7 @@ export type FeatureFlagConfigMap = { }; export type FeatureFlagValues = { - [K in FeatureFlag]: boolean; + [K in FeatureFlag]?: boolean; }; export type FeatureFlagContext = diff --git a/packages/sdk/src/server/routers/task-runs.ts b/packages/sdk/src/server/routers/task-runs.ts index 51807dbff..6ec0a5fab 100644 --- a/packages/sdk/src/server/routers/task-runs.ts +++ b/packages/sdk/src/server/routers/task-runs.ts @@ -8,6 +8,7 @@ import { getTaskGoalForRun, isNotNull, releaseTaskGoalContinuationForRun, + sessionTasks, slackInstallations, taskPullRequests, } from '@roomote/db/server'; @@ -401,10 +402,15 @@ export const taskRunsRouter = router({ .input(enqueueTaskInputSchema) .mutation(async ({ input }) => { const launchResult = await enqueueTask(input as EnqueueTaskInput); + const linkedSession = await db.query.sessionTasks.findFirst({ + where: eq(sessionTasks.taskId, launchResult.taskId), + columns: { sessionId: true }, + }); return { id: launchResult.id, taskId: launchResult.taskId, + sessionId: linkedSession?.sessionId, }; }), dequeue: runScoped( diff --git a/packages/slack/package.json b/packages/slack/package.json index 1789320c0..d4aea38fb 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -23,6 +23,7 @@ "@roomote/communication": "workspace:^", "@roomote/db": "workspace:^", "@roomote/env": "workspace:^", + "@roomote/feature-flags": "workspace:^", "@roomote/redis": "workspace:^", "@roomote/types": "workspace:^", "@slack/web-api": "^7.19.0", diff --git a/packages/slack/src/fast-agent-live-task-launcher.ts b/packages/slack/src/fast-agent-live-task-launcher.ts index c533965b0..f23431910 100644 --- a/packages/slack/src/fast-agent-live-task-launcher.ts +++ b/packages/slack/src/fast-agent-live-task-launcher.ts @@ -4,9 +4,16 @@ import { type LaunchFastAgentTask, } from '@roomote/cloud-agents/server'; import { RunStatus } from '@roomote/types'; +import { Env } from '@roomote/env'; +import { db, getSessionForTask } from '@roomote/db/server'; +import { + evaluateDeploymentFeatureFlag, + FeatureFlag, +} from '@roomote/feature-flags/server'; import { buildSlackLiveTaskCardBlocks, SLACK_LIVE_TASK_CARD_MESSAGES, + SLACK_SESSION_LIVE_TASK_CARD_MESSAGES, } from './live-task-card-blocks'; import { buildSlackLiveTaskTitle, @@ -22,6 +29,7 @@ type SlackLiveTaskCardNotifier = Pick< >; export const STARTING_TASK_TITLE = 'Starting task…'; +export const PREPARING_WORKSPACE_TITLE = 'Preparing workspace…'; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -49,13 +57,17 @@ export function createFastAgentSlackLiveTaskLauncher( ): LaunchFastAgentTask { const { slack, ...launcherParams } = params; - const postTaskLink = async (taskUrl: string): Promise => { + const postTaskLink = async ( + taskUrl: string, + sessionMode = false, + ): Promise => { + const label = sessionMode ? 'Open in Roomote' : 'Open the task'; try { await slack.postMessage({ channel: launcherParams.channelId, thread_ts: launcherParams.threadTs, - text: `Open the task: ${taskUrl}`, - blocks: [{ type: 'markdown', text: `[Open the task](${taskUrl})` }], + text: `${label}: ${taskUrl}`, + blocks: [{ type: 'markdown', text: `[${label}](${taskUrl})` }], unfurl_links: false, unfurl_media: false, }); @@ -72,8 +84,19 @@ export function createFastAgentSlackLiveTaskLauncher( ): Promise => { const taskUpdateId = `roomote-task-${taskRun.taskId}`; let messageTs: string | undefined; + let sessionMode = false; + let destinationUrl = context.taskUrl; try { + sessionMode = await evaluateDeploymentFeatureFlag( + FeatureFlag.SessionsComms, + ); + const linkedSession = sessionMode + ? await getSessionForTask(db, taskRun.taskId) + : null; + destinationUrl = linkedSession + ? `${Env.R_APP_URL}/sessions/${linkedSession.id}?task=${taskRun.taskId}` + : context.taskUrl; // A card for this task already exists (for example an idempotent // relaunch of the same task); keep updating it instead of posting // a second card in the thread. @@ -86,9 +109,10 @@ export function createFastAgentSlackLiveTaskLauncher( thread_ts: launcherParams.threadTs, ...buildSlackLiveTaskCardBlocks({ taskUpdateId, - title: STARTING_TASK_TITLE, + title: sessionMode ? PREPARING_WORKSPACE_TITLE : STARTING_TASK_TITLE, status: 'in_progress', - taskUrl: context.taskUrl, + taskUrl: destinationUrl, + sessionMode, }), unfurl_links: false, unfurl_media: false, @@ -104,7 +128,7 @@ export function createFastAgentSlackLiveTaskLauncher( console.warn( `[Fast Agent] Slack rejected the task card for run ${taskRun.id} (${posted.slackErrorCode ?? (posted.transportError ? 'transport error' : 'unknown')}); posting the task link instead.`, ); - await postTaskLink(context.taskUrl); + await postTaskLink(destinationUrl, sessionMode); return; } @@ -118,7 +142,8 @@ export function createFastAgentSlackLiveTaskLauncher( taskUpdateId, threadTs: launcherParams.threadTs, title: buildSlackLiveTaskTitle(context.prompt), - taskUrl: context.taskUrl, + taskUrl: destinationUrl, + ...(sessionMode ? { sessionMode: true } : {}), }); } catch (error) { console.error( @@ -140,10 +165,15 @@ export function createFastAgentSlackLiveTaskLauncher( ts: messageTs, message: buildSlackLiveTaskCardBlocks({ taskUpdateId, - title: STARTING_TASK_TITLE, + title: sessionMode + ? PREPARING_WORKSPACE_TITLE + : STARTING_TASK_TITLE, status: 'error', - message: SLACK_LIVE_TASK_CARD_MESSAGES.trackingUnavailable, - taskUrl: context.taskUrl, + message: sessionMode + ? SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.trackingUnavailable + : SLACK_LIVE_TASK_CARD_MESSAGES.trackingUnavailable, + taskUrl: destinationUrl, + sessionMode, }), }); } catch (updateError) { @@ -152,7 +182,7 @@ export function createFastAgentSlackLiveTaskLauncher( ); } if (!settled) { - await postTaskLink(context.taskUrl); + await postTaskLink(destinationUrl, sessionMode); } } }; diff --git a/packages/slack/src/live-task-card-blocks.ts b/packages/slack/src/live-task-card-blocks.ts index 81fdea7df..df83b16de 100644 --- a/packages/slack/src/live-task-card-blocks.ts +++ b/packages/slack/src/live-task-card-blocks.ts @@ -16,6 +16,14 @@ export const SLACK_LIVE_TASK_CARD_MESSAGES = { 'Live updates are unavailable for this task; open it to follow progress.', } as const; +export const SLACK_SESSION_LIVE_TASK_CARD_MESSAGES = { + completed: 'Ready.', + canceled: 'Stopped.', + failed: 'Stopped because of an error.', + trackingUnavailable: + 'Live updates are unavailable; open Roomote to follow progress.', +} as const; + export interface SlackLiveTaskCardContent { taskUpdateId: string; title: string; @@ -24,6 +32,7 @@ export interface SlackLiveTaskCardContent { * the card output. Always the latest one, never accumulated. */ message?: string; taskUrl?: string; + sessionMode?: boolean; } /** @@ -53,7 +62,9 @@ export function buildSlackLiveTaskCardBlocks( text: [ content.title, message, - content.taskUrl ? `<${content.taskUrl}|Open the task>` : undefined, + content.taskUrl + ? `<${content.taskUrl}|${content.sessionMode ? 'Open in Roomote' : 'Open the task'}>` + : undefined, ] .filter((line): line is string => Boolean(line)) .join('\n'), @@ -68,7 +79,11 @@ export function buildSlackLiveTaskCardBlocks( ...(content.taskUrl ? { sources: [ - { type: 'url', url: content.taskUrl, text: 'View task' }, + { + type: 'url', + url: content.taskUrl, + text: content.sessionMode ? 'Open in Roomote' : 'View task', + }, ], } : {}), diff --git a/packages/slack/src/live-task-stream.ts b/packages/slack/src/live-task-stream.ts index 20aaf9005..f4bfdac2b 100644 --- a/packages/slack/src/live-task-stream.ts +++ b/packages/slack/src/live-task-stream.ts @@ -15,6 +15,7 @@ export interface SlackLiveTaskStreamData { threadTs: string; title: string; taskUrl?: string; + sessionMode?: boolean; } // Keyed by task id: runs are replaced on snapshot resume, but the card in the diff --git a/packages/slack/src/settle-live-task-card.ts b/packages/slack/src/settle-live-task-card.ts index ca039a551..a9312e3b4 100644 --- a/packages/slack/src/settle-live-task-card.ts +++ b/packages/slack/src/settle-live-task-card.ts @@ -4,6 +4,7 @@ import { and, db, eq, slackInstallations } from '@roomote/db/server'; import { buildSlackLiveTaskCardBlocks, SLACK_LIVE_TASK_CARD_MESSAGES, + SLACK_SESSION_LIVE_TASK_CARD_MESSAGES, } from './live-task-card-blocks'; import { buildSlackLiveTaskTitle, @@ -74,6 +75,7 @@ export async function renderSlackLiveTaskCard(input: { status: input.status, ...(input.message ? { message: input.message } : {}), ...(data.taskUrl ? { taskUrl: data.taskUrl } : {}), + sessionMode: data.sessionMode === true, }), }); @@ -105,8 +107,8 @@ export async function settleSlackLiveTaskCardForRun(input: { status: 'error', message: input.status === RunStatus.Canceled - ? SLACK_LIVE_TASK_CARD_MESSAGES.canceled - : SLACK_LIVE_TASK_CARD_MESSAGES.failed, + ? await dataSessionMessages(input.taskId, 'canceled') + : await dataSessionMessages(input.taskId, 'failed'), taskTitle: input.taskTitle, }); } catch (error) { @@ -115,3 +117,13 @@ export async function settleSlackLiveTaskCardForRun(input: { ); } } + +async function dataSessionMessages( + taskId: string, + state: 'canceled' | 'failed', +): Promise { + const data = await getSlackLiveTaskStreamData(taskId); + return data?.sessionMode + ? SLACK_SESSION_LIVE_TASK_CARD_MESSAGES[state] + : SLACK_LIVE_TASK_CARD_MESSAGES[state]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 864f12396..ce5fe8716 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,9 @@ importers: '@roomote/env': specifier: workspace:^ version: link:../../packages/env + '@roomote/feature-flags': + specifier: workspace:^ + version: link:../../packages/feature-flags '@roomote/github': specifier: workspace:^ version: link:../../packages/github @@ -1136,6 +1139,9 @@ importers: '@roomote/env': specifier: workspace:^ version: link:../env + '@roomote/feature-flags': + specifier: workspace:^ + version: link:../feature-flags '@roomote/gitea': specifier: workspace:^ version: link:../gitea @@ -1645,6 +1651,9 @@ importers: '@roomote/env': specifier: workspace:^ version: link:../env + '@roomote/feature-flags': + specifier: workspace:^ + version: link:../feature-flags '@roomote/redis': specifier: workspace:^ version: link:../redis