From 5967e7e06d4b7a8e709fca80b923ae1066ebe0c3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 16:40:05 +0000 Subject: [PATCH 01/39] feat(db): add unified session schema foundation --- .../src/__tests__/schema-constraints.test.ts | 136 +++++++++ packages/db/src/fixtures/factories/index.ts | 1 + .../src/fixtures/factories/session.factory.ts | 36 +++ .../db/src/lib/__tests__/sessions.test.ts | 248 ++++++++++++++++ packages/db/src/lib/sessions.ts | 269 ++++++++++++++++++ packages/db/src/schema.ts | 201 +++++++++++++ packages/db/src/server.ts | 12 + packages/db/src/types.ts | 25 ++ 8 files changed, 928 insertions(+) create mode 100644 packages/db/src/fixtures/factories/session.factory.ts create mode 100644 packages/db/src/lib/__tests__/sessions.test.ts create mode 100644 packages/db/src/lib/sessions.ts 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..ac93b97d1 --- /dev/null +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -0,0 +1,248 @@ +import { + db, + eq, + fastAgentConversations, + sessionFactory, + sessionParticipants, + sessions, + sessionTasks, + taskFactory, + tasks, + userFactory, + users, +} from '../../server'; + +import { + deriveSessionStatus, + 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('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('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); + }); +}); diff --git a/packages/db/src/lib/sessions.ts b/packages/db/src/lib/sessions.ts new file mode 100644 index 000000000..0db3daa32 --- /dev/null +++ b/packages/db/src/lib/sessions.ts @@ -0,0 +1,269 @@ +import { and, desc, eq, sql } from 'drizzle-orm'; + +import type { TaskGoalStatus, TaskState } from '@roomote/types'; + +import type { DatabaseOrTransaction } from '../db'; +import { + sessionParticipants, + sessions, + sessionTasks, + taskRuns, + tasks, + type SessionStatus, + type SessionTaskOrigin, +} from '../schema'; +import type { Session } from '../types'; + +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( + tx: DatabaseOrTransaction, + sessionId: string, + at: number, + options: { conversationResponding?: 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(eq(sessionTasks.sessionId, sessionId)) + .orderBy(tasks.id, desc(taskRuns.id)); + + const [updated] = await tx + .update(sessions) + .set({ + activityAt: sql`GREATEST(${sessions.activityAt}, ${at})`, + 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; +}; + +/** + * 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 findSessionForTask(tx, task.id); + if (existing) { + return existing; + } + + let session = input.fastConversationId + ? await findSessionForFastConversation(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 findSessionForFastConversation(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 findSessionForTask(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); +} + +async function findSessionForTask( + 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; +} + +async function findSessionForFastConversation( + 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; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f23ac5f45..76ec5af03 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, { @@ -3184,6 +3187,7 @@ export const fastAgentConversationsRelations = relations( }), messages: many(fastAgentMessages), prFeedbackDeliveries: many(fastAgentPrFeedbackDeliveries), + session: one(sessions), }), ); @@ -3447,10 +3451,207 @@ 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'; + +/** + * 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' }), + 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')`, + ), + ], +); + +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), +})); + +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], + }), + }), +); + /** * custom_automations * diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index c171152d6..eba4174d1 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,12 @@ export { tasksRelations, taskPins, taskPinsRelations, + sessions, + sessionsRelations, + sessionTasks, + sessionTasksRelations, + sessionParticipants, + sessionParticipantsRelations, taskArtifacts, taskArtifactsRelations, taskPullRequests, @@ -240,5 +247,10 @@ export type { SuggestionType, ManagerMcpSetupNotificationReason, EnvironmentConfigVersionSource, + SessionOwnerKind, + SessionSourceSurface, + SessionStatus, + SessionTaskOrigin, + SessionParticipantRole, } from './schema'; export type { AutomationWorkItemDisposition } from '@roomote/types'; diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 863c6d9df..f93a1333d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -23,6 +23,9 @@ import type { deploymentSettings, tasks, taskPins, + sessions, + sessionTasks, + sessionParticipants, taskPullRequests, taskRuns, taskRunEvents, @@ -99,6 +102,28 @@ 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 +>; + /** * taskPullRequests */ From 8c891d6513de3a3c0488b585ceb4d1ad275a3630 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 16:48:33 +0000 Subject: [PATCH 02/39] fix(db): serialize session status refreshes --- .../db/src/lib/__tests__/sessions.test.ts | 66 +++++++++++++++++++ packages/db/src/lib/sessions.ts | 29 ++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/db/src/lib/__tests__/sessions.test.ts b/packages/db/src/lib/__tests__/sessions.test.ts index ac93b97d1..9754c6d62 100644 --- a/packages/db/src/lib/__tests__/sessions.test.ts +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -128,6 +128,72 @@ describe('session helpers', () => { ); }); + 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); diff --git a/packages/db/src/lib/sessions.ts b/packages/db/src/lib/sessions.ts index 0db3daa32..25e591572 100644 --- a/packages/db/src/lib/sessions.ts +++ b/packages/db/src/lib/sessions.ts @@ -14,6 +14,8 @@ import { } from '../schema'; import type { Session } from '../types'; +import { runInTransactionIfAvailable } from './transaction-utils'; + export type SessionStatusInput = { conversationResponding: boolean; tasks: Array<{ @@ -55,10 +57,31 @@ export function deriveSessionStatus(input: SessionStatusInput): SessionStatus { } export async function touchSessionActivity( - tx: DatabaseOrTransaction, + dbOrTx: DatabaseOrTransaction, sessionId: string, at: number, options: { conversationResponding?: 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 }, ): Promise { const linkedTasks = await tx .selectDistinctOn([tasks.id], { @@ -85,9 +108,7 @@ export async function touchSessionActivity( .where(eq(sessions.id, sessionId)) .returning(); - if (!updated) { - throw new Error(`Session ${sessionId} does not exist.`); - } + if (!updated) throw new Error(`Session ${sessionId} does not exist.`); return updated; } From c22e9d8e1ac2a1924dd2b3e18f7cbc4fd04c80e2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 19:17:52 +0000 Subject: [PATCH 03/39] feat: complete unified sessions rollout --- apps/bullmq/package.json | 1 + .../__tests__/sessions-reconcile.test.ts | 62 ++ apps/bullmq/src/scheduled-jobs/index.ts | 1 + .../src/scheduled-jobs/sessions-reconcile.ts | 250 +++++++ apps/bullmq/src/scheduler.ts | 7 + apps/bullmq/src/types.ts | 1 + apps/docs/fast-sessions.mdx | 54 +- apps/docs/tasks.mdx | 26 +- .../(authenticated)/analytics/Analytics.tsx | 10 +- .../analytics/AnalyticsDetailsDialog.tsx | 2 + .../analytics/AnalyticsDimensionIcons.ts | 3 + .../analytics/AnalyticsFilterBar.tsx | 2 + .../analytics/AnalyticsShell.tsx | 4 + .../web/src/app/(authenticated)/home/Home.tsx | 22 +- .../(authenticated)/sessions/SessionCard.tsx | 87 +++ .../sessions/SessionsFilters.tsx | 222 +++++- .../src/app/(authenticated)/sessions/page.tsx | 124 +++- .../src/app/(authenticated)/tasks/page.tsx | 26 +- .../[sessionId]/FastSessionTranscript.tsx | 15 +- .../[sessionId]/SessionReadTracker.tsx | 38 + .../sessions/[sessionId]/SessionTaskCards.tsx | 169 +++++ .../sessions/[sessionId]/SessionWorkspace.tsx | 239 ++++++- .../sessions/[sessionId]/page.test.tsx | 4 + .../(sandbox)/sessions/[sessionId]/page.tsx | 97 ++- .../app/(sandbox)/task/[taskId]/Header.tsx | 86 ++- .../task/[taskId]/TaskSessionReadTracker.tsx | 28 + .../src/components/layout/CommandPalette.tsx | 34 +- .../components/layout/navbar/NavbarDrawer.tsx | 7 +- .../src/components/layout/navigation-items.ts | 16 +- .../components/layout/side-nav/SideNav.tsx | 10 +- apps/web/src/hooks/useRecentSessions.ts | 42 ++ apps/web/src/lib/server/analytics/index.ts | 14 + .../src/lib/server/analytics/session-rows.ts | 84 +++ apps/web/src/lib/server/auth-context.test.ts | 10 +- apps/web/src/lib/server/sessions.test.ts | 146 ++++ apps/web/src/lib/server/sessions.ts | 660 ++++++++++++++++++ apps/web/src/lib/telemetry/normalize-path.ts | 7 +- .../src/trpc/commands/fast-sessions/index.ts | 17 +- .../trpc/commands/feature-flags/index.test.ts | 22 +- .../src/trpc/commands/sessions/index.test.ts | 63 ++ apps/web/src/trpc/commands/sessions/index.ts | 104 +++ apps/web/src/trpc/commands/task-runs/index.ts | 10 +- apps/web/src/trpc/routers/_app.ts | 79 +++ apps/web/src/types/analytics.ts | 34 +- packages/cloud-agents/package.json | 1 + .../src/server/__tests__/enqueue-task.test.ts | 56 ++ .../__tests__/fast-agent-service.test.ts | 3 + .../fast-agent-conversation-repository.ts | 69 ++ .../server/fast-agent/fast-agent-service.ts | 64 +- .../src/server/non-task-provider-usage.ts | 4 + .../cloud-agents/src/server/task-run-queue.ts | 48 ++ .../communication/src/fast-session-footer.ts | 16 +- .../db/src/lib/__tests__/sessions.test.ts | 135 ++++ packages/db/src/lib/llm-usage.ts | 19 +- packages/db/src/lib/sessions.ts | 202 +++++- packages/db/src/lib/sync-task-state.ts | 3 + packages/db/src/schema.ts | 79 +++ packages/db/src/server.ts | 4 + packages/db/src/types.ts | 5 + .../src/__tests__/config.test.ts | 16 +- .../evaluateFlagFromMetadata.test.ts | 22 +- packages/feature-flags/src/config.ts | 21 +- packages/feature-flags/src/types.ts | 8 +- packages/sdk/src/server/routers/task-runs.ts | 6 + packages/slack/package.json | 1 + .../src/fast-agent-live-task-launcher.ts | 54 +- packages/slack/src/live-task-card-blocks.ts | 19 +- packages/slack/src/live-task-stream.ts | 1 + packages/slack/src/settle-live-task-card.ts | 16 +- pnpm-lock.yaml | 9 + 70 files changed, 3640 insertions(+), 180 deletions(-) create mode 100644 apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts create mode 100644 apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts create mode 100644 apps/web/src/app/(authenticated)/sessions/SessionCard.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx create mode 100644 apps/web/src/hooks/useRecentSessions.ts create mode 100644 apps/web/src/lib/server/analytics/session-rows.ts create mode 100644 apps/web/src/lib/server/sessions.test.ts create mode 100644 apps/web/src/lib/server/sessions.ts create mode 100644 apps/web/src/trpc/commands/sessions/index.test.ts create mode 100644 apps/web/src/trpc/commands/sessions/index.ts 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..76438cd00 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -0,0 +1,62 @@ +import { + db, + deploymentSettings, + eq, + fastAgentConversations, + sessionTasks, + sessions, + taskFactory, + userFactory, +} from '@roomote/db/server'; +import { getFeatureFlagEvaluator } from '@roomote/feature-flags/server'; + +import { getRedis } from '../../redis'; +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 } }, + }); + await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); + }); + + afterEach(async () => { + await db + .update(deploymentSettings) + .set({ metadata: {} }) + .where(eq(deploymentSettings.id, 'default')); + await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); + }); + + 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..3f40866f9 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -0,0 +1,250 @@ +import { + and, + db, + desc, + ensureSessionForFastConversation, + ensureSessionForTask, + eq, + fastAgentConversations, + gt, + isNull, + or, + sessionBackfillState, + sessions, + sessionTasks, + sql, + taskRuns, + tasks, + touchSessionActivity, +} from '@roomote/db/server'; +import { + FeatureFlag, + getFeatureFlagEvaluator, +} from '@roomote/feature-flags/server'; + +import { getRedis } from '../redis'; + +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 getFeatureFlagEvaluator(getRedis()).evaluate( + FeatureFlag.SessionsData, + { isDeploymentContext: true }, + ); + 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 45b4d93e9..ef0c684c4 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -1,39 +1,47 @@ --- -title: Fast sessions -icon: zap -description: Chat with the fast orchestrator from the dashboard and review every Fast conversation's 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 is one -persisted Fast conversation, whether it started in Slack, Discord, 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. -## 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 conversation 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. ## Reply to a session -Every session has a reply box at the bottom of the transcript; follow-ups +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, 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 conversation stays in one place for everyone following it there. Fast replies -in Slack and Discord carry the same "Reply or use the web app" footer as task -replies, linking to the session view. +in supported communications providers link back with **Open in Roomote**. + +## 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 0bac4e8b7..ca6852ee0 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.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 803e5b274..3b493ab95 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -148,8 +148,10 @@ export function Home({ const { cloudEnabled, isAdmin, + featureFlags, managedAccess = DEFAULT_MANAGED_DEPLOYMENT_ACCESS, } = useAuthorizedUser(); + const sessionsUiEnabled = featureFlags?.sessions_ui === true; const canSelectBranch = false; @@ -406,11 +408,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); } @@ -655,6 +662,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; @@ -688,6 +705,7 @@ export function Home({ wiggleWorkspace, startFastSession, selectedModelId, + sessionsUiEnabled, ], ); @@ -722,7 +740,7 @@ export function Home({
diff --git a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx new file mode 100644 index 000000000..e0fb69848 --- /dev/null +++ b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx @@ -0,0 +1,87 @@ +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +import { getUserDisplayName } from '@/lib'; +import { Avatar, Badge } from '@/components/system'; + +type SessionCardData = { + id: string; + title: string; + ownerName: string | null; + ownerEmail: string | null; + ownerImageUrl: string | null; + sourceSurface: string; + activityAt: number; + cachedStatus: 'active' | 'needs_input' | 'blocked' | 'ready' | null; + executionCount: number; + inferenceCostMicroUsd: number; + unread: boolean; + tasks: Array<{ + taskId: string; + workflow: string; + repositoryName: string | null; + }>; +}; + +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)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8066694fc..2b1bd801f 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, @@ -61,6 +68,8 @@ export function FastSessionTranscript({ sessionReasoningEffort = null, defaultModelId = null, defaultReasoningEffort = null, + headerExtras, + timelineExtras, }: { sessionId: string; initialMessages: FastSessionMessage[]; @@ -72,6 +81,8 @@ export function FastSessionTranscript({ sessionReasoningEffort?: ReasoningEffort | null; defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; + headerExtras?: ReactNode; + timelineExtras?: ReactNode; }) { const trpcClient = useTRPCClient(); const [serverMessages, setServerMessages] = useState< @@ -259,6 +270,7 @@ export function FastSessionTranscript({

{title ?? fallbackTitle}

+ {headerExtras} @@ -267,6 +279,7 @@ export function FastSessionTranscript({ Older messages in this session are not shown.

) : null} + {timelineExtras} { + 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.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index ade5d409c..d60249c0e 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,10 +1,13 @@ 'use client'; -import { useState, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { formatDistanceToNow } from 'date-fns'; import { formatInferenceCost, getUserDisplayName } from '@/lib'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; +import { useIsMobile } from '@/hooks/useIsMobile'; import { WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { @@ -17,7 +20,18 @@ import { ResizablePanel, ResizablePanelGroup, X, + Rows4, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, } from '@/components/system'; +import type { SessionTaskSummary } from './SessionTaskCards'; export type SessionInfo = { id: string; @@ -29,6 +43,7 @@ export type SessionInfo = { model: string | null; inferenceCostMicroUsd: number; createdAt: Date; + tasks: SessionTaskSummary[]; }; const SURFACE_LABELS: Record = { @@ -47,6 +62,110 @@ function InfoRow({ label, children }: { label: string; children: ReactNode }) { ); } +function SessionTaskPanel({ + sessionId, + task, + tasks, + onSelect, + onClose, +}: { + sessionId: string; + task: SessionTaskSummary; + tasks: SessionTaskSummary[]; + onSelect: (taskId: string) => void; + onClose: () => void; +}) { + return ( + <> +
+

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 SessionInfoPanel({ session, onClose, @@ -128,6 +247,47 @@ export function SessionWorkspace({ children: ReactNode; }) { const [isInfoOpen, setIsInfoOpen] = useState(false); + const isMobile = useIsMobile(); + const router = useRouter(); + const searchParams = useSearchParams(); + const selectedTaskId = searchParams.get('task'); + const selectedTask = session.tasks.find( + (task) => task.taskId === selectedTaskId, + ); + const panelOpen = isInfoOpen || Boolean(selectedTask); + + 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 (!selectedTaskId && session.tasks.length === 1) { + selectTask(session.tasks[0]!.taskId); + } + }, [selectTask, selectedTaskId, session.tasks]); + + const closePanel = () => { + setIsInfoOpen(false); + selectTask(null); + }; + const panelContent = selectedTask ? ( + + ) : ( + + ); return ( setIsInfoOpen((previous) => !previous)} /> + {session.tasks.length ? ( + + selectTask(selectedTask ? null : session.tasks[0]!.taskId) + } + /> + ) : null} } > - - - {children} - - {isInfoOpen && ( - <> - - - setIsInfoOpen(false)} - /> - - - )} - + {isMobile ? ( + <> +
{children}
+ { + if (!open) closePanel(); + }} + > + + + Session execution details + + {panelContent} + + + + ) : ( + + + {children} + + {panelOpen ? ( + <> + + + {panelContent} + + + ) : 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 8d8250dc6..93aebc7ea 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -14,6 +14,10 @@ const { authorizeMock, getFastSessionByIdMock, transcriptMock } = vi.hoisted( ); 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, })); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index c042a4893..9e37bb646 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -9,9 +9,14 @@ import { import { authorize } from '@/lib/server/auth-context'; import { getFastSessionById } 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 +31,96 @@ export default async function SessionDetailPage({ notFound(); } - const session = await getFastSessionById(authorizedUser, sessionId); + const unifiedSession = authorizedUser.featureFlags?.sessions_ui + ? await getSessionByIdCommand(authorizedUser, sessionId) + : null; + 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, + inferenceCostMicroUsd: unifiedSession.inferenceCostMicroUsd, + createdAt: unifiedSession.createdAt, + 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(); } @@ -53,6 +147,7 @@ export default async function SessionDetailPage({ model: session.model ?? defaultModelId, inferenceCostMicroUsd: session.inferenceCostMicroUsd, createdAt: session.createdAt, + tasks: [], }; const initialUserMessage = session.messages.find( (message) => message.role === 'user', diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 282df1030..e1dd8f108 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -1,7 +1,9 @@ '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, @@ -12,6 +14,12 @@ import { 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 +28,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,9 +37,18 @@ 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; @@ -150,21 +168,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 ( <> + {parentSession ? ( + + ) : null} -

- {title} -

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

+ {title} +

+ )} {badges.length > 0 && (
{badges.map((badge, index) => ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx new file mode 100644 index 000000000..cff3edc13 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useTRPCClient } from '@/trpc/client'; + +export function TaskSessionReadTracker({ sessionId }: { sessionId: string }) { + const trpc = useTRPCClient(); + + useEffect(() => { + 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); + return () => window.removeEventListener('focus', markRead); + }, [sessionId, trpc]); + + return null; +} diff --git a/apps/web/src/components/layout/CommandPalette.tsx b/apps/web/src/components/layout/CommandPalette.tsx index b45af7eb7..bc0d521c5 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: user?.featureFlags?.sessions_ui ? 'Sessions' : 'Tasks', + href: user?.featureFlags?.sessions_ui ? '/sessions' : '/tasks', + }, { icon: Settings, label: 'Settings', href: '/settings' }, { icon: HelpCircle, label: 'Help', action: 'contact-support' }, ]; @@ -133,7 +137,7 @@ function AuthorizedCommandPalette() { ); } return items; - }, [user?.isAdmin]); + }, [user?.featureFlags?.sessions_ui, user?.isAdmin]); // Debounce search input useEffect(() => { @@ -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/navbar/NavbarDrawer.tsx b/apps/web/src/components/layout/navbar/NavbarDrawer.tsx index e821b965c..066357025 100644 --- a/apps/web/src/components/layout/navbar/NavbarDrawer.tsx +++ b/apps/web/src/components/layout/navbar/NavbarDrawer.tsx @@ -17,8 +17,11 @@ import { getVisiblePrimaryNavItems } from '../navigation-items'; export const NavbarDrawer = () => { const pathname = usePathname(); - const { isAdmin } = useAuthorizedUser(); - const visibleNavItems = getVisiblePrimaryNavItems({ isAdmin }); + const { isAdmin, featureFlags } = useAuthorizedUser(); + const visibleNavItems = getVisiblePrimaryNavItems({ + isAdmin, + sessionsUi: featureFlags?.sessions_ui === true, + }); const [open, setOpen] = useState(false); diff --git a/apps/web/src/components/layout/navigation-items.ts b/apps/web/src/components/layout/navigation-items.ts index da059f972..3436fd698 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; @@ -52,6 +51,17 @@ const PRIMARY_NAV_ITEMS: PrimaryNavItem[] = [ export function getVisiblePrimaryNavItems(opts: { isAdmin: boolean; + sessionsUi?: boolean; }): PrimaryNavItem[] { - return PRIMARY_NAV_ITEMS.filter((item) => !item.adminOnly || opts.isAdmin); + return PRIMARY_NAV_ITEMS.map((item) => + item.href === '/tasks' && opts.sessionsUi + ? { + ...item, + href: '/sessions', + label: 'Sessions', + description: 'View conversations and delegated work', + matchPaths: ['/sessions', '/tasks', '/cloud-agents'], + } + : item, + ).filter((item) => !item.adminOnly || opts.isAdmin); } diff --git a/apps/web/src/components/layout/side-nav/SideNav.tsx b/apps/web/src/components/layout/side-nav/SideNav.tsx index de06fdb48..24f1aafc6 100644 --- a/apps/web/src/components/layout/side-nav/SideNav.tsx +++ b/apps/web/src/components/layout/side-nav/SideNav.tsx @@ -71,7 +71,7 @@ export const SideNav = () => { const pathname = usePathname(); const { setOpen: openCommandPalette } = useCommandPalette(); - const { isAdmin } = useAuthorizedUser(); + const { isAdmin, featureFlags } = useAuthorizedUser(); const hasHydrated = useLayoutStore((state) => state.hasHydrated); const persistedIsSideNavExpanded = useLayoutStore( (state) => state.isSideNavExpanded, @@ -204,8 +204,12 @@ export const SideNav = () => { return Array.from(groups.values()); }, [environmentNameById, nonPinnedQuickAccessTasks]); const visibleNavItems = useMemo( - () => getVisiblePrimaryNavItems({ isAdmin }), - [isAdmin], + () => + getVisiblePrimaryNavItems({ + isAdmin, + sessionsUi: featureFlags?.sessions_ui === true, + }), + [featureFlags?.sessions_ui, isAdmin], ); return ( 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/sessions.test.ts b/apps/web/src/lib/server/sessions.test.ts new file mode 100644 index 000000000..4160f8d23 --- /dev/null +++ b/apps/web/src/lib/server/sessions.test.ts @@ -0,0 +1,146 @@ +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('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..c57df6bb8 --- /dev/null +++ b/apps/web/src/lib/server/sessions.ts @@ -0,0 +1,660 @@ +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 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, + deletedAt: tasks.deletedAt, + }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where(eq(sessionTasks.sessionId, sessionId)) + .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); + if (!session) return null; + const [hydrated] = await hydrateSessionRows(auth, [session]); + const sessionTaskDetails = await getSessionTasks(sessionId); + 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 218c2dcdc..20f808fbf 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -12,7 +12,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, @@ -148,7 +153,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, @@ -182,7 +187,13 @@ 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 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/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 4e1c286ac..56396abb6 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -39,6 +39,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, @@ -2825,6 +2838,72 @@ export const appRouter = createRouter({ ), }), + 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({ get: protectedProcedure.query(({ ctx: { auth } }) => getAgentBehaviorSettingsCommand(auth), 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 6e0220d91..7c854cfd0 100644 --- a/packages/cloud-agents/package.json +++ b/packages/cloud-agents/package.json @@ -64,6 +64,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..a81f38008 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -39,10 +39,13 @@ import { environments, environmentRepositoryMappings, repositories, + sessionTasks, userFactory, environmentFactory, repositoryFactory, } from '@roomote/db/server'; +import { getFeatureFlagEvaluator } from '@roomote/feature-flags/server'; +import { getRedis } from '@roomote/redis'; import { TaskRunQueue, @@ -909,6 +912,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 } }, + }); + await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); + }); + + afterEach(async () => { + await db + .update(deploymentSettings) + .set({ metadata: {} }) + .where(eq(deploymentSettings.id, 'default')); + await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); + }); + + 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 4ba076f97..00cd06ab6 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 @@ -77,6 +77,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/fast-agent-conversation-repository.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts index dfa628abe..bef16b9c1 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,19 @@ import { eq, fastAgentConversations, fastAgentMessages, + ensureSessionForFastConversation, + advanceSessionNotifiedCursor, + advanceSessionReadCursor, + getSessionForFastConversation, sql, + touchSessionActivity, type DatabaseOrTransaction, } from '@roomote/db/server'; +import { + FeatureFlag, + getFeatureFlagEvaluator, +} from '@roomote/feature-flags/server'; +import { getRedis } from '@roomote/redis'; import { fastAgentConversationSchema } from '@roomote/types'; import type { FastAgentConversation } from './fast-agent-conversation'; @@ -56,6 +66,15 @@ export interface FastAgentConversationRepository { }): Promise; } +async function sessionsDataEnabled(): Promise { + return getFeatureFlagEvaluator(getRedis()).evaluate( + FeatureFlag.SessionsData, + { + isDeploymentContext: true, + }, + ); +} + function buildIdentityKey(conversation: FastAgentConversation): string { return `${conversation.surface}:${conversation.workspaceId}:${conversation.conversationId}`; } @@ -151,6 +170,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))`, @@ -207,6 +227,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); }); }, @@ -274,6 +298,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = return; } + const touchSession = await sessionsDataEnabled(); await db.transaction(async (tx) => { const conversationId = await resolveCanonicalId(tx, requestedId); await tx.execute( @@ -290,10 +315,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( @@ -335,6 +375,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 73568576a..5522bf3b5 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 @@ -22,8 +22,16 @@ import { appendFastAgentMemory, db, getDeploymentTaskModelOptions, + getSessionForFastConversation, + getSessionForTask, isBrainProviderConfigured, + touchSessionActivity, } from '@roomote/db/server'; +import { + FeatureFlag, + getFeatureFlagEvaluator, +} from '@roomote/feature-flags/server'; +import { getRedis } from '@roomote/redis'; import { Env } from '@roomote/env'; import { z } from 'zod'; @@ -109,6 +117,17 @@ const chatReactionArgsSchema = 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, @@ -956,6 +975,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); @@ -1450,12 +1474,34 @@ export async function answerFastAgentQuestion({ taskUrl?: string; taskLinkRendered?: boolean; }) => { + let sessionCommsEnabled = false; + let linkedSession: Awaited> = + null; + try { + sessionCommsEnabled = await getFeatureFlagEvaluator( + getRedis(), + ).evaluate(FeatureFlag.SessionsComms, { + isDeploymentContext: true, + }); + 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)) @@ -1703,6 +1749,7 @@ export async function answerFastAgentQuestion({ return await generateTrackedNonTaskTextInOpenCodeSession( { userId, + fastConversationId: session.id, surface: NON_TASK_INFERENCE_SURFACES.fastAgentQuestionAnswering, modelRole: FAST_AGENT_MODEL_ROLE, @@ -1963,6 +2010,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/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..63b94befb 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 { + FeatureFlag, + getFeatureFlagEvaluator, +} 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 getFeatureFlagEvaluator( + getRedis(), + ).evaluate(FeatureFlag.SessionsData, { isDeploymentContext: true }); 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/communication/src/fast-session-footer.ts b/packages/communication/src/fast-session-footer.ts index 131d24fc0..919586109 100644 --- a/packages/communication/src/fast-session-footer.ts +++ b/packages/communication/src/fast-session-footer.ts @@ -6,7 +6,11 @@ import { } from './chat-messages'; import { chunkDiscordMessage } from './discord-provider'; -export type FastSessionFooterProvider = 'slack' | 'discord'; +export type FastSessionFooterProvider = + | 'slack' + | 'discord' + | 'teams' + | 'telegram'; export function buildFastSessionUrl( provider: FastSessionFooterProvider, @@ -36,10 +40,12 @@ export function buildFastSessionReplyFooterText(params: { explicitMentionRequired: false, ...(params.provider === 'slack' ? { formatLink: (label: string, url: string) => `<${url}|${label}>` } - : { - formatLink: formatMarkdownLink, - formatFooterText: (text: string) => `-# ${text}`, - }), + : params.provider === 'telegram' + ? { formatLink: (label: string, url: string) => `${label} (${url})` } + : { + formatLink: formatMarkdownLink, + formatFooterText: (text: string) => `-# ${text}`, + }), }); } diff --git a/packages/db/src/lib/__tests__/sessions.test.ts b/packages/db/src/lib/__tests__/sessions.test.ts index 9754c6d62..342e5da13 100644 --- a/packages/db/src/lib/__tests__/sessions.test.ts +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -2,6 +2,8 @@ import { db, eq, fastAgentConversations, + llmUsageEvents, + recordLlmUsage, sessionFactory, sessionParticipants, sessions, @@ -13,7 +15,10 @@ import { } from '../../server'; import { + advanceSessionReadCursor, + advanceSessionNotifiedCursor, deriveSessionStatus, + ensureSessionForFastConversation, ensureSessionForTask, touchSessionActivity, } from '../sessions'; @@ -104,6 +109,21 @@ describe('session helpers', () => { 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, @@ -311,4 +331,119 @@ describe('session helpers', () => { .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 index 25e591572..44ab88d72 100644 --- a/packages/db/src/lib/sessions.ts +++ b/packages/db/src/lib/sessions.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, sql } from 'drizzle-orm'; +import { and, desc, eq, or, sql } from 'drizzle-orm'; import type { TaskGoalStatus, TaskState } from '@roomote/types'; @@ -7,6 +7,7 @@ import { sessionParticipants, sessions, sessionTasks, + fastAgentConversations, taskRuns, tasks, type SessionStatus, @@ -60,7 +61,10 @@ export async function touchSessionActivity( dbOrTx: DatabaseOrTransaction, sessionId: string, at: number, - options: { conversationResponding?: boolean } = {}, + options: { + conversationResponding?: boolean; + recomputeStatus?: boolean; + } = {}, ): Promise { return runInTransactionIfAvailable(dbOrTx, async (tx) => { const [lockedSession] = await tx @@ -81,7 +85,7 @@ async function refreshLockedSession( tx: DatabaseOrTransaction, sessionId: string, at: number, - options: { conversationResponding?: boolean }, + options: { conversationResponding?: boolean; recomputeStatus?: boolean }, ): Promise { const linkedTasks = await tx .selectDistinctOn([tasks.id], { @@ -99,10 +103,14 @@ async function refreshLockedSession( .update(sessions) .set({ activityAt: sql`GREATEST(${sessions.activityAt}, ${at})`, - cachedStatus: deriveSessionStatus({ - conversationResponding: options.conversationResponding ?? false, - tasks: linkedTasks, - }), + ...(options.recomputeStatus === false + ? {} + : { + cachedStatus: deriveSessionStatus({ + conversationResponding: options.conversationResponding ?? false, + tasks: linkedTasks, + }), + }), updatedAt: new Date(), }) .where(eq(sessions.id, sessionId)) @@ -120,6 +128,69 @@ export type EnsureSessionForTaskInput = { 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. @@ -154,13 +225,13 @@ export async function ensureSessionForTask( return null; } - const existing = await findSessionForTask(tx, task.id); + const existing = await getSessionForTask(tx, task.id); if (existing) { return existing; } let session = input.fastConversationId - ? await findSessionForFastConversation(tx, input.fastConversationId) + ? await getSessionForFastConversation(tx, input.fastConversationId) : null; let createdCandidate = false; @@ -211,7 +282,7 @@ export async function ensureSessionForTask( session = inserted ?? (input.fastConversationId - ? await findSessionForFastConversation(tx, input.fastConversationId) + ? await getSessionForFastConversation(tx, input.fastConversationId) : null); createdCandidate = inserted !== undefined; } @@ -231,7 +302,7 @@ export async function ensureSessionForTask( .returning({ sessionId: sessionTasks.sessionId }); if (!attached) { - const canonical = await findSessionForTask(tx, task.id); + const canonical = await getSessionForTask(tx, task.id); if (!canonical) { throw new Error(`Failed to attach task ${task.id} to a Session.`); } @@ -257,7 +328,7 @@ export async function ensureSessionForTask( return touchSessionActivity(tx, session.id, task.activityAt); } -async function findSessionForTask( +export async function getSessionForTask( tx: DatabaseOrTransaction, taskId: string, ): Promise { @@ -271,7 +342,7 @@ async function findSessionForTask( return session?.session ?? null; } -async function findSessionForFastConversation( +export async function getSessionForFastConversation( tx: DatabaseOrTransaction, fastConversationId: string, ): Promise { @@ -288,3 +359,108 @@ async function findSessionForFastConversation( 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 76ec5af03..0ca976309 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1795,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. @@ -1862,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, @@ -1887,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( @@ -3465,6 +3473,11 @@ export type SessionTaskOrigin = | 'backfill' | 'follow_up'; export type SessionParticipantRole = 'owner' | 'member'; +export type SessionBackfillPhase = + | 'fast_conversations' + | 'fast_tasks' + | 'tasks' + | 'participants'; /** * sessions @@ -3593,6 +3606,7 @@ export const sessionParticipants = pgTable( 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(), }, @@ -3609,6 +3623,58 @@ export const sessionParticipants = pgTable( ], ); +/** 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], @@ -3625,6 +3691,8 @@ export const sessionsRelations = relations(sessions, ({ one, many }) => ({ }), tasks: many(sessionTasks), participants: many(sessionParticipants), + pins: many(sessionPins), + usageEvents: many(llmUsageEvents), })); export const sessionTasksRelations = relations(sessionTasks, ({ one }) => ({ @@ -3652,6 +3720,17 @@ export const sessionParticipantsRelations = relations( }), ); +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 eba4174d1..a617550ba 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -126,6 +126,9 @@ export { sessionTasksRelations, sessionParticipants, sessionParticipantsRelations, + sessionPins, + sessionPinsRelations, + sessionBackfillState, taskArtifacts, taskArtifactsRelations, taskPullRequests, @@ -252,5 +255,6 @@ export type { 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 f93a1333d..67ab8e2f1 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -26,6 +26,8 @@ import type { sessions, sessionTasks, sessionParticipants, + sessionPins, + sessionBackfillState, taskPullRequests, taskRuns, taskRunEvents, @@ -124,6 +126,9 @@ export type CreateSessionParticipant = Omit< 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/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..e767da0b0 100644 --- a/packages/slack/src/fast-agent-live-task-launcher.ts +++ b/packages/slack/src/fast-agent-live-task-launcher.ts @@ -4,9 +4,17 @@ 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 { + FeatureFlag, + getFeatureFlagEvaluator, +} from '@roomote/feature-flags/server'; +import { getRedis } from '@roomote/redis'; import { buildSlackLiveTaskCardBlocks, SLACK_LIVE_TASK_CARD_MESSAGES, + SLACK_SESSION_LIVE_TASK_CARD_MESSAGES, } from './live-task-card-blocks'; import { buildSlackLiveTaskTitle, @@ -22,6 +30,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 +58,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 +85,20 @@ export function createFastAgentSlackLiveTaskLauncher( ): Promise => { const taskUpdateId = `roomote-task-${taskRun.taskId}`; let messageTs: string | undefined; + let sessionMode = false; + let destinationUrl = context.taskUrl; try { + sessionMode = await getFeatureFlagEvaluator(getRedis()).evaluate( + FeatureFlag.SessionsComms, + { isDeploymentContext: true }, + ); + 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 +111,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 +130,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 +144,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 +167,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 +184,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 bd0da3dbc..2309d1aff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,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 @@ -1139,6 +1142,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 @@ -1639,6 +1645,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 From 7e5240f842c9da50e4b82170d1d9e4f82651f4ec Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 19:24:40 +0000 Subject: [PATCH 04/39] chore: regenerate unified sessions migration --- .../drizzle/0061_careful_lily_hollister.sql | 89 + packages/db/drizzle/meta/0061_snapshot.json | 13717 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + 3 files changed, 13813 insertions(+) create mode 100644 packages/db/drizzle/0061_careful_lily_hollister.sql create mode 100644 packages/db/drizzle/meta/0061_snapshot.json diff --git a/packages/db/drizzle/0061_careful_lily_hollister.sql b/packages/db/drizzle/0061_careful_lily_hollister.sql new file mode 100644 index 000000000..446cd427a --- /dev/null +++ b/packages/db/drizzle/0061_careful_lily_hollister.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/0061_snapshot.json b/packages/db/drizzle/meta/0061_snapshot.json new file mode 100644 index 000000000..cc86040ba --- /dev/null +++ b/packages/db/drizzle/meta/0061_snapshot.json @@ -0,0 +1,13717 @@ +{ + "id": "7fb9bf55-5bfb-4854-a7c0-348575cb6c30", + "prevId": "df5ebdeb-4055-46c6-87fc-a10c618013f3", + "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 + }, + "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.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 0e44b199a..3a228ef2b 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -428,6 +428,13 @@ "when": 1787765765044, "tag": "0060_organic_harrier", "breakpoints": true + }, + { + "idx": 61, + "version": "7", + "when": 1787772010337, + "tag": "0061_careful_lily_hollister", + "breakpoints": true } ] } From f82c6ff8bbbd7471a527327cd6c11f9701ec6ad0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 27 Aug 2026 09:35:50 +0000 Subject: [PATCH 05/39] fix: avoid Redis dependency in session flag checks --- .../__tests__/sessions-reconcile.test.ts | 5 ----- .../src/scheduled-jobs/sessions-reconcile.ts | 9 ++------ .../src/server/__tests__/enqueue-task.test.ts | 4 ---- .../fast-agent-conversation-repository.ts | 10 ++------- .../server/fast-agent/fast-agent-service.ts | 11 ++++------ .../cloud-agents/src/server/task-run-queue.ts | 8 +++---- .../feature-flags/src/server/deployment.ts | 21 +++++++++++++++++++ packages/feature-flags/src/server/index.ts | 1 + .../src/fast-agent-live-task-launcher.ts | 6 ++---- 9 files changed, 36 insertions(+), 39 deletions(-) create mode 100644 packages/feature-flags/src/server/deployment.ts diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts index 76438cd00..ccdc8aab2 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -8,9 +8,6 @@ import { taskFactory, userFactory, } from '@roomote/db/server'; -import { getFeatureFlagEvaluator } from '@roomote/feature-flags/server'; - -import { getRedis } from '../../redis'; import { sessionsReconcileJob } from '../sessions-reconcile'; describe('sessionsReconcileJob', () => { @@ -22,7 +19,6 @@ describe('sessionsReconcileJob', () => { target: deploymentSettings.id, set: { metadata: { sessions_data: true } }, }); - await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); }); afterEach(async () => { @@ -30,7 +26,6 @@ describe('sessionsReconcileJob', () => { .update(deploymentSettings) .set({ metadata: {} }) .where(eq(deploymentSettings.id, 'default')); - await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); }); it('backfills Fast conversations and visible tasks idempotently', async () => { diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts index 3f40866f9..470217c30 100644 --- a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -18,12 +18,10 @@ import { touchSessionActivity, } from '@roomote/db/server'; import { + evaluateDeploymentFeatureFlag, FeatureFlag, - getFeatureFlagEvaluator, } from '@roomote/feature-flags/server'; -import { getRedis } from '../redis'; - const LOG_PREFIX = '[sessions]'; const BACKFILL_KEY = 'unified-sessions-v1'; const BATCH_SIZE = 100; @@ -214,10 +212,7 @@ async function reconcileRecentSessions(): Promise { } export async function sessionsReconcileJob(): Promise { - const enabled = await getFeatureFlagEvaluator(getRedis()).evaluate( - FeatureFlag.SessionsData, - { isDeploymentContext: true }, - ); + const enabled = await evaluateDeploymentFeatureFlag(FeatureFlag.SessionsData); if (!enabled) return; const state = await db.query.sessionBackfillState.findFirst({ 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 a81f38008..a28e5ac20 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -44,8 +44,6 @@ import { environmentFactory, repositoryFactory, } from '@roomote/db/server'; -import { getFeatureFlagEvaluator } from '@roomote/feature-flags/server'; -import { getRedis } from '@roomote/redis'; import { TaskRunQueue, @@ -921,7 +919,6 @@ describe('enqueueTask Session linkage', () => { target: deploymentSettings.id, set: { metadata: { sessions_data: true } }, }); - await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); }); afterEach(async () => { @@ -929,7 +926,6 @@ describe('enqueueTask Session linkage', () => { .update(deploymentSettings) .set({ metadata: {} }) .where(eq(deploymentSettings.id, 'default')); - await getFeatureFlagEvaluator(getRedis()).invalidateDeploymentCache(); }); it('creates exactly one Session link for a visible fresh task', async () => { 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 bef16b9c1..1d4fa192e 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 @@ -15,10 +15,9 @@ import { type DatabaseOrTransaction, } from '@roomote/db/server'; import { + evaluateDeploymentFeatureFlag, FeatureFlag, - getFeatureFlagEvaluator, } from '@roomote/feature-flags/server'; -import { getRedis } from '@roomote/redis'; import { fastAgentConversationSchema } from '@roomote/types'; import type { FastAgentConversation } from './fast-agent-conversation'; @@ -67,12 +66,7 @@ export interface FastAgentConversationRepository { } async function sessionsDataEnabled(): Promise { - return getFeatureFlagEvaluator(getRedis()).evaluate( - FeatureFlag.SessionsData, - { - isDeploymentContext: true, - }, - ); + return evaluateDeploymentFeatureFlag(FeatureFlag.SessionsData); } function buildIdentityKey(conversation: FastAgentConversation): string { 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 5522bf3b5..edf295b45 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 @@ -28,10 +28,9 @@ import { touchSessionActivity, } from '@roomote/db/server'; import { + evaluateDeploymentFeatureFlag, FeatureFlag, - getFeatureFlagEvaluator, } from '@roomote/feature-flags/server'; -import { getRedis } from '@roomote/redis'; import { Env } from '@roomote/env'; import { z } from 'zod'; @@ -1478,11 +1477,9 @@ export async function answerFastAgentQuestion({ let linkedSession: Awaited> = null; try { - sessionCommsEnabled = await getFeatureFlagEvaluator( - getRedis(), - ).evaluate(FeatureFlag.SessionsComms, { - isDeploymentContext: true, - }); + sessionCommsEnabled = await evaluateDeploymentFeatureFlag( + FeatureFlag.SessionsComms, + ); linkedSession = sessionCommsEnabled ? await getSessionForTask(db, task.taskId) : null; diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 63b94befb..5466f6fa7 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -73,8 +73,8 @@ import { sql, } from '@roomote/db/server'; import { + evaluateDeploymentFeatureFlag, FeatureFlag, - getFeatureFlagEvaluator, } from '@roomote/feature-flags/server'; import { type Redis, getRedis } from '@roomote/redis'; import { @@ -1390,9 +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 getFeatureFlagEvaluator( - getRedis(), - ).evaluate(FeatureFlag.SessionsData, { isDeploymentContext: true }); + const sessionsDataEnabled = await evaluateDeploymentFeatureFlag( + FeatureFlag.SessionsData, + ); await assertUserIsNotDeleted(linkedUserId); diff --git a/packages/feature-flags/src/server/deployment.ts b/packages/feature-flags/src/server/deployment.ts new file mode 100644 index 000000000..668e82b67 --- /dev/null +++ b/packages/feature-flags/src/server/deployment.ts @@ -0,0 +1,21 @@ +import { db, deploymentSettings, eq } from '@roomote/db/server'; + +import { evaluateFeatureFlagFromMetadata } from '../index'; +import type { FeatureFlag } from '../types'; + +const DEFAULT_DEPLOYMENT_ID = 'default'; + +/** + * 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 { + const deployment = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), + columns: { metadata: true }, + }); + + return evaluateFeatureFlagFromMetadata(flag, deployment?.metadata); +} diff --git a/packages/feature-flags/src/server/index.ts b/packages/feature-flags/src/server/index.ts index d17f03f23..e51415ce2 100644 --- a/packages/feature-flags/src/server/index.ts +++ b/packages/feature-flags/src/server/index.ts @@ -4,4 +4,5 @@ export { resetFeatureFlagEvaluatorForTests, } from '../evaluator'; export { MetadataCache } from '../cache'; +export { evaluateDeploymentFeatureFlag } from './deployment'; export * from '../index'; diff --git a/packages/slack/src/fast-agent-live-task-launcher.ts b/packages/slack/src/fast-agent-live-task-launcher.ts index e767da0b0..f23431910 100644 --- a/packages/slack/src/fast-agent-live-task-launcher.ts +++ b/packages/slack/src/fast-agent-live-task-launcher.ts @@ -7,10 +7,9 @@ import { RunStatus } from '@roomote/types'; import { Env } from '@roomote/env'; import { db, getSessionForTask } from '@roomote/db/server'; import { + evaluateDeploymentFeatureFlag, FeatureFlag, - getFeatureFlagEvaluator, } from '@roomote/feature-flags/server'; -import { getRedis } from '@roomote/redis'; import { buildSlackLiveTaskCardBlocks, SLACK_LIVE_TASK_CARD_MESSAGES, @@ -89,9 +88,8 @@ export function createFastAgentSlackLiveTaskLauncher( let destinationUrl = context.taskUrl; try { - sessionMode = await getFeatureFlagEvaluator(getRedis()).evaluate( + sessionMode = await evaluateDeploymentFeatureFlag( FeatureFlag.SessionsComms, - { isDeploymentContext: true }, ); const linkedSession = sessionMode ? await getSessionForTask(db, taskRun.taskId) From 4cc33a3294fcbcc5efe4b9c4a0ba05774f66f0a5 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 27 Aug 2026 10:04:35 +0000 Subject: [PATCH 06/39] fix: cache deployment feature flag metadata --- .../src/server/__tests__/enqueue-task.test.ts | 3 + packages/feature-flags/src/evaluator.ts | 3 + .../src/server/deployment.test.ts | 85 +++++++++++++++++++ .../feature-flags/src/server/deployment.ts | 57 +++++++++++-- packages/feature-flags/src/server/index.ts | 5 +- 5 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 packages/feature-flags/src/server/deployment.test.ts 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 a28e5ac20..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'), @@ -919,6 +920,7 @@ describe('enqueueTask Session linkage', () => { target: deploymentSettings.id, set: { metadata: { sessions_data: true } }, }); + invalidateDeploymentFeatureFlagCache(); }); afterEach(async () => { @@ -926,6 +928,7 @@ describe('enqueueTask Session linkage', () => { .update(deploymentSettings) .set({ metadata: {} }) .where(eq(deploymentSettings.id, 'default')); + invalidateDeploymentFeatureFlagCache(); }); it('creates exactly one Session link for a visible fresh task', async () => { 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 index 668e82b67..caf3560d5 100644 --- a/packages/feature-flags/src/server/deployment.ts +++ b/packages/feature-flags/src/server/deployment.ts @@ -4,6 +4,56 @@ 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 @@ -12,10 +62,5 @@ const DEFAULT_DEPLOYMENT_ID = 'default'; export async function evaluateDeploymentFeatureFlag( flag: FeatureFlag, ): Promise { - const deployment = await db.query.deploymentSettings.findFirst({ - where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), - columns: { metadata: true }, - }); - - return evaluateFeatureFlagFromMetadata(flag, deployment?.metadata); + return evaluateFeatureFlagFromMetadata(flag, await getDeploymentMetadata()); } diff --git a/packages/feature-flags/src/server/index.ts b/packages/feature-flags/src/server/index.ts index e51415ce2..753eb31f1 100644 --- a/packages/feature-flags/src/server/index.ts +++ b/packages/feature-flags/src/server/index.ts @@ -4,5 +4,8 @@ export { resetFeatureFlagEvaluatorForTests, } from '../evaluator'; export { MetadataCache } from '../cache'; -export { evaluateDeploymentFeatureFlag } from './deployment'; +export { + evaluateDeploymentFeatureFlag, + invalidateDeploymentFeatureFlagCache, +} from './deployment'; export * from '../index'; From d127a47ca8e0cf177087d1713f875952a3c8660c Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 27 Aug 2026 10:43:09 +0000 Subject: [PATCH 07/39] fix: resolve session review findings --- .../(authenticated)/home/Home.client.test.tsx | 24 +++++++ .../web/src/app/(authenticated)/home/Home.tsx | 6 +- .../task/[taskId]/Header.client.test.tsx | 63 +++++++++++++++++-- .../app/(sandbox)/task/[taskId]/Header.tsx | 13 ++-- apps/web/src/lib/server/sessions.test.ts | 34 ++++++++++ apps/web/src/lib/server/sessions.ts | 3 +- .../commands/tasks/__tests__/delete.test.ts | 16 +++++ apps/web/src/trpc/commands/tasks/delete.ts | 14 +++++ .../db/src/lib/__tests__/sessions.test.ts | 22 +++++++ packages/db/src/lib/sessions.ts | 4 +- 10 files changed, 184 insertions(+), 15 deletions(-) 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 10393dd16..f18cadd36 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -569,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); 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..3858daa04 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,19 @@ 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, + featureFlagState, +} = vi.hoisted(() => ({ + useSandboxLayoutMock: vi.fn(), + useTRPCMock: vi.fn(), + updateTitleMutationMock: vi.fn(async () => undefined), + parentSessionQueryMock: vi.fn(), + featureFlagState: { sessionsUiEnabled: false }, +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: useSandboxLayoutMock, @@ -16,6 +23,16 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ + featureFlags: { sessions_ui: featureFlagState.sessionsUiEnabled }, + }), +})); + +vi.mock('./TaskSessionReadTracker', () => ({ + TaskSessionReadTracker: () => null, +})); + vi.mock('@/components/sandbox', () => ({ WorkspaceBadge: ({ environmentId, @@ -78,6 +95,11 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + featureFlagState.sessionsUiEnabled = false; + parentSessionQueryMock.mockResolvedValue({ + sessionId: 'session-1', + title: 'Parent Session', + }); useSandboxLayoutMock.mockReturnValue({ isSidebarVisible: true, @@ -93,6 +115,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 +176,23 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); + it('does not query or render Session breadcrumbs while Sessions UI is disabled', () => { + renderHeader(); + + expect(parentSessionQueryMock).not.toHaveBeenCalled(); + expect(screen.queryByRole('link', { name: 'Sessions' })).toBeNull(); + }); + + it('renders Session breadcrumbs while Sessions UI is enabled', async () => { + featureFlagState.sessionsUiEnabled = true; + + renderHeader(); + + expect( + await screen.findByRole('link', { name: 'Parent Session' }), + ).toHaveAttribute('href', '/sessions/session-1?task=task-123'); + }); + 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 e1dd8f108..74f22f331 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -25,6 +25,7 @@ import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; import { useTRPC } from '@/trpc/client'; +import { useAuthorizedUser } from '@/hooks/useUser'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; @@ -37,18 +38,22 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); + const { featureFlags } = useAuthorizedUser(); + const sessionsUiEnabled = featureFlags?.sessions_ui === true; const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); - const parentSessionOptions = trpc.sessions?.forTask?.queryOptions({ - taskId, - }) ?? { + const parentSessionOptions = trpc.sessions?.forTask?.queryOptions( + { taskId }, + { enabled: sessionsUiEnabled }, + ) ?? { queryKey: ['sessions', 'for-task', 'disabled', taskId], queryFn: async () => null, enabled: false, }; - const { data: parentSession } = useQuery(parentSessionOptions); + const { data: queriedParentSession } = useQuery(parentSessionOptions); + const parentSession = sessionsUiEnabled ? queriedParentSession : null; const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; diff --git a/apps/web/src/lib/server/sessions.test.ts b/apps/web/src/lib/server/sessions.test.ts index 4160f8d23..a0e9239ad 100644 --- a/apps/web/src/lib/server/sessions.test.ts +++ b/apps/web/src/lib/server/sessions.test.ts @@ -116,6 +116,40 @@ describe('unified Session queries', () => { 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(); diff --git a/apps/web/src/lib/server/sessions.ts b/apps/web/src/lib/server/sessions.ts index c57df6bb8..3c7cc4644 100644 --- a/apps/web/src/lib/server/sessions.ts +++ b/apps/web/src/lib/server/sessions.ts @@ -433,11 +433,10 @@ async function getSessionTasks(sessionId: string) { repositoryName: tasks.repositoryName, model: tasks.model, activityAt: tasks.activityAt, - deletedAt: tasks.deletedAt, }) .from(sessionTasks) .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) - .where(eq(sessionTasks.sessionId, sessionId)) + .where(and(eq(sessionTasks.sessionId, sessionId), isNull(tasks.deletedAt))) .orderBy(sessionTasks.attachedAt); return Promise.all( 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/packages/db/src/lib/__tests__/sessions.test.ts b/packages/db/src/lib/__tests__/sessions.test.ts index 342e5da13..a4f88241f 100644 --- a/packages/db/src/lib/__tests__/sessions.test.ts +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -148,6 +148,28 @@ describe('session helpers', () => { ); }); + 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, diff --git a/packages/db/src/lib/sessions.ts b/packages/db/src/lib/sessions.ts index 44ab88d72..b53c51461 100644 --- a/packages/db/src/lib/sessions.ts +++ b/packages/db/src/lib/sessions.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, or, sql } from 'drizzle-orm'; +import { and, desc, eq, isNull, or, sql } from 'drizzle-orm'; import type { TaskGoalStatus, TaskState } from '@roomote/types'; @@ -96,7 +96,7 @@ async function refreshLockedSession( .from(sessionTasks) .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) .leftJoin(taskRuns, eq(taskRuns.taskId, tasks.id)) - .where(eq(sessionTasks.sessionId, sessionId)) + .where(and(eq(sessionTasks.sessionId, sessionId), isNull(tasks.deletedAt))) .orderBy(tasks.id, desc(taskRuns.id)); const [updated] = await tx From d411f66d791546c5f4b43b73320f206f9843f498 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 13:17:25 +0000 Subject: [PATCH 08/39] feat: add live nested task panels to web sessions --- .../FastSessionTranscript.client.test.tsx | 92 ++++++++++++- .../[sessionId]/FastSessionTranscript.tsx | 4 + .../NestedTaskSidePanel.client.test.tsx | 93 +++++++++++++ .../[sessionId]/NestedTaskSidePanel.tsx | 124 ++++++++++++++++++ .../SessionWorkspace.client.test.tsx | 37 +++++- .../sessions/[sessionId]/SessionWorkspace.tsx | 117 ++++++++++------- .../[sessionId]/session-task-panel-context.ts | 11 ++ .../[taskId]/messages/acp/AcpMessageItem.tsx | 12 ++ .../messages/acp/AcpTranscriptBlocks.tsx | 8 ++ .../acp/DelegatedTaskCard.client.test.tsx | 98 ++++++++++++++ .../messages/acp/DelegatedTaskCard.tsx | 60 +++++++++ .../tool-call-grouping.client.test.ts | 23 ++++ .../[taskId]/messages/acp/activity-groups.ts | 23 +++- .../[taskId]/messages/acp/delegated-task.ts | 50 +++++++ .../[taskId]/messages/acp/render-blocks.ts | 7 +- 15 files changed, 699 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts 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..deb6c375b 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( >( @@ -185,6 +187,7 @@ export function FastSessionTranscript({ shouldHideFirstMessage: false, showInternalMessages: false, hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); @@ -284,6 +287,7 @@ export function FastSessionTranscript({ blocks={renderBlocks} showInternalMessages={false} onSuppress={suppressMessage} + onOpenDelegatedTask={openTaskPanel ?? undefined} /> 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..0ec29566d --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -0,0 +1,124 @@ +'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 { 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]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx index 169a932c0..b3703e869 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 @@ -3,6 +3,7 @@ import { fireEvent, render, screen } 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(), @@ -23,6 +24,12 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('./NestedTaskSidePanel', () => ({ + NestedTaskSidePanel: ({ taskId }: { taskId: string }) => ( +
Nested panel {taskId}
+ ), +})); + const session: SessionInfo = { id: 'session-1', ownerName: 'Test User', @@ -51,18 +58,32 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { ); } -function renderWorkspace({ isMobile }: { isMobile: boolean }) { +function renderWorkspace({ + isMobile, + children =
Session transcript
, +}: { + isMobile: boolean; + children?: ReactNode; +}) { useMediaQueryMock.mockReturnValue(!isMobile); render( - -
Session transcript
-
+ {children}
, ); } +function OpenNestedTask() { + const openTaskPanel = useOpenSessionTaskPanel(); + + return ( + + ); +} + describe('SessionWorkspace', () => { it('matches the task sidebar replacement behavior and controls on mobile', () => { renderWorkspace({ isMobile: true }); @@ -109,4 +130,12 @@ describe('SessionWorkspace', () => { screen.getByRole('button', { name: 'Close session info' }), ).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 30b254ef8..15826ebb1 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -32,6 +32,8 @@ import { SandboxSideActions, } from '../../SandboxWorkspacePanels'; import { useSandboxLayout } from '../../use-sandbox-layout'; +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; +import { OpenSessionTaskPanelContext } from './session-task-panel-context'; export type SessionInfo = { id: string; @@ -241,13 +243,15 @@ export function SessionWorkspace({ children: ReactNode; }) { const [isInfoOpen, setIsInfoOpen] = useState(false); + const [nestedTaskId, setNestedTaskId] = useState(null); const router = useRouter(); const searchParams = useSearchParams(); const selectedTaskId = searchParams.get('task'); const selectedTask = session.tasks.find( (task) => task.taskId === selectedTaskId, ); - const panelOpen = isInfoOpen || Boolean(selectedTask); + const panelOpen = + isInfoOpen || Boolean(selectedTask) || Boolean(nestedTaskId); const selectTask = useCallback( (taskId: string | null) => { @@ -266,11 +270,22 @@ export function SessionWorkspace({ } }, [selectTask, selectedTaskId, session.tasks]); + const openTaskPanel = useCallback( + (taskId: string) => { + setIsInfoOpen(false); + setNestedTaskId(taskId); + selectTask(null); + }, + [selectTask], + ); const closePanel = () => { setIsInfoOpen(false); + setNestedTaskId(null); selectTask(null); }; - const panelContent = selectedTask ? ( + const panelContent = nestedTaskId ? ( + + ) : selectedTask ? ( - - { - selectTask(null); - setIsInfoOpen((previous) => !previous); - }} - /> - {session.tasks.length ? ( + + + { - setIsInfoOpen(false); - selectTask(selectedTask ? null : session.tasks[0]!.taskId); + setNestedTaskId(null); + selectTask(null); + setIsInfoOpen((previous) => !previous); }} /> + {session.tasks.length ? ( + { + setNestedTaskId(null); + setIsInfoOpen(false); + selectTask(selectedTask ? null : session.tasks[0]!.taskId); + }} + /> + ) : null} + + {!isSidebarVisible && !panelOpen ? ( + + + ) : null} - - {!isSidebarVisible && !panelOpen ? ( - - - - ) : null} - - } - > - - + + } + > + + + ); } 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]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx index ddc9bdcf1..d2f0ebc86 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx @@ -8,11 +8,14 @@ import { AcpTodoSectionMessage } from './AcpTodoSectionMessage'; import { AcpTextMessage } from './AcpTextMessage'; import { AcpToolMessage } from './AcpToolMessage'; import { AcpUnknownMessage } from './AcpUnknownMessage'; +import { DelegatedTaskCard } from './DelegatedTaskCard'; +import { getDelegatedTaskDetails } from './delegated-task'; interface AcpMessageItemProps { msg: AcpUiMessage; onSuppress?: (messageId: string) => void; showSubagentPayload?: boolean; + onOpenDelegatedTask?: (taskId: string) => void; children?: ReactNode; } @@ -20,6 +23,7 @@ function AcpMessageItemBase({ msg, onSuppress, showSubagentPayload = false, + onOpenDelegatedTask, children, }: AcpMessageItemProps) { switch (msg.kind) { @@ -31,6 +35,14 @@ function AcpMessageItemBase({ return ; case 'tool_call': case 'tool_result': { + const delegatedTask = getDelegatedTaskDetails(msg); + + if (delegatedTask && onOpenDelegatedTask) { + return ( + + ); + } + return msg.data.kind === 'execute' ? ( >( @@ -63,6 +65,7 @@ export function useAcpTranscriptBlocks({ initialPrompt, shouldHideFirstMessage, showInternalMessages, + keepDelegatedTasksVisible, suppressedMessageIds, }); @@ -70,12 +73,14 @@ export function useAcpTranscriptBlocks({ artifacts, displayMode, hasLeadingTextBoundary, + keepDelegatedTasksVisible, }); }, [ artifacts, displayMode, hasLeadingTextBoundary, initialPrompt, + keepDelegatedTasksVisible, messages, shouldHideFirstMessage, showInternalMessages, @@ -106,10 +111,12 @@ export function AcpTranscriptBlockList({ blocks, showInternalMessages, onSuppress, + onOpenDelegatedTask, }: { blocks: AcpConversationRenderBlock[]; showInternalMessages: boolean; onSuppress: (messageId: string) => void; + onOpenDelegatedTask?: (taskId: string) => void; }) { function renderNestedBlocks(nestedBlocks: AcpRenderBlock[]) { return nestedBlocks.map((block) => ( @@ -196,6 +203,7 @@ export function AcpTranscriptBlockList({ msg={block.msg} onSuppress={onSuppress} showSubagentPayload={showInternalMessages} + onOpenDelegatedTask={onOpenDelegatedTask} > {block.childBlocks?.length ? renderNestedBlocks(block.childBlocks) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx new file mode 100644 index 000000000..0205c52ca --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { RunStatus } from '@roomote/types'; + +const useQueryMock = vi.fn(); +const queryOptionsMock = vi.fn((input, options) => ({ input, ...options })); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sandboxSession: { byTaskId: { queryOptions: queryOptionsMock } }, + }), +})); + +import { DelegatedTaskCard } from './DelegatedTaskCard'; + +describe('DelegatedTaskCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + useQueryMock.mockReturnValue({ + isPending: false, + data: { + task: { title: 'Fix checkout' }, + taskRun: { + status: RunStatus.Running, + taskPhase: 'running', + error: null, + }, + }, + }); + }); + + it('renders live task state and opens the selected child', () => { + const onOpen = vi.fn(); + render( + , + ); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByText('Working')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'View delegated task: Fix checkout' }), + ); + expect(onOpen).toHaveBeenCalledWith('child-1'); + + const queryOptions = queryOptionsMock.mock.calls[0]![1]; + expect(queryOptions.refetchInterval({ state: { data: undefined } })).toBe( + 2_000, + ); + expect(queryOptionsMock).toHaveBeenCalledWith( + { taskId: 'child-1' }, + expect.any(Object), + ); + }); + + it('updates when the child transitions to a terminal state', () => { + let queryResult = { + isPending: false, + data: { + task: { title: 'Fix checkout' }, + taskRun: { + status: RunStatus.Running, + taskPhase: 'running', + error: null as string | null, + }, + }, + }; + useQueryMock.mockImplementation(() => queryResult); + + const { rerender } = render( + , + ); + expect(screen.getByText('Working')).toBeInTheDocument(); + + queryResult = { + ...queryResult, + data: { + ...queryResult.data, + taskRun: { + status: RunStatus.Failed, + taskPhase: 'shutting_down', + error: 'Task failed', + }, + }, + }; + rerender( + , + ); + + expect(screen.getByText('Error')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx new file mode 100644 index 000000000..dc2834459 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { Bot, ChevronRight, Skeleton } from '@/components/system'; +import { TaskStatusIndicator } from '@/components/sandbox'; +import { useTRPC } from '@/trpc/client'; + +export function DelegatedTaskCard({ + taskId, + prompt, + onOpen, +}: { + taskId: string; + prompt: string | null; + onOpen: (taskId: string) => void; +}) { + const trpc = useTRPC(); + const { data, isPending } = useQuery( + trpc.sandboxSession.byTaskId.queryOptions( + { taskId }, + { + refetchInterval: (query) => query.state.data?.refetchInterval ?? 2_000, + }, + ), + ); + const title = data?.task?.title?.trim() || prompt || 'Delegated task'; + + return ( + + ); +} 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..65148e00d --- /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; + +export 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), }; } From 6adcd59cd82c04f581c72d2d0f71837a836986ae Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 13:19:04 +0000 Subject: [PATCH 09/39] chore: keep delegated task details private --- .../app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 65148e00d..488e97bc8 100644 --- 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 @@ -2,7 +2,7 @@ import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; -export interface DelegatedTaskDetails { +interface DelegatedTaskDetails { taskId: string; prompt: string | null; } From 3e4f8ed39c53f739b02c3c707bf78cb7b36e8c9b Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 18:22:50 +0100 Subject: [PATCH 10/39] refine delegated task card --- .../acp/DelegatedTaskCard.client.test.tsx | 3 +- .../messages/acp/DelegatedTaskCard.tsx | 11 ++--- .../fast-agent-task-launcher.test.ts | 46 ++++++++++++++++++- .../fast-agent/fast-agent-task-launcher.ts | 1 + 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx index 0205c52ca..f6d845cae 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx @@ -43,9 +43,10 @@ describe('DelegatedTaskCard', () => { ); expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByText('Started coding task')).toBeInTheDocument(); expect(screen.getByText('Working')).toBeInTheDocument(); fireEvent.click( - screen.getByRole('button', { name: 'View delegated task: Fix checkout' }), + screen.getByRole('button', { name: 'View coding task: Fix checkout' }), ); expect(onOpen).toHaveBeenCalledWith('child-1'); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx index dc2834459..8047cea93 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; -import { Bot, ChevronRight, Skeleton } from '@/components/system'; +import { ChevronRight, Skeleton } from '@/components/system'; import { TaskStatusIndicator } from '@/components/sandbox'; import { useTRPC } from '@/trpc/client'; @@ -29,16 +29,13 @@ export function DelegatedTaskCard({ return ( + ) : null} {!isSidebarVisible && (
)} - {parentSession ? ( + {sessionHref ? ( - - )} -
- - {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}
-
- + )} + ); } From 2c3347cf466594ce53dc0964b00697754bd9e6d0 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 18:48:29 +0100 Subject: [PATCH 18/39] fix: frame session info panel --- .../sessions/[sessionId]/SessionWorkspace.tsx | 133 +++++++++--------- 1 file changed, 69 insertions(+), 64 deletions(-) diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 89977e198..dd400f38a 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -7,7 +7,7 @@ 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 { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { ArrowLeftFromLine, @@ -240,72 +240,77 @@ function SessionInfoPanel({ const surfaceBrandIcon = SURFACE_BRAND_ICONS[session.surface]; return ( - - - - - - {ownerDisplayName} - - - - - - {modelAndReasoningLabel} - - - - - - {inferenceCostLabel} - - - - - - - {session.createdAt.toLocaleString(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - })} - - - - - - {session.surface === 'slack' ? ( - - ) : surfaceBrandIcon ? ( - + + + + - ) : ( - - )} - {surfaceLabel} - - - {session.status ? ( - - - {session.status.replace('_', ' ')} - + {ownerDisplayName} + - ) : null} - - + + + + {modelAndReasoningLabel} + + + + + + {inferenceCostLabel} + + + + + + + {session.createdAt.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + })} + + + + + + {session.surface === 'slack' ? ( + + ) : surfaceBrandIcon ? ( + + ) : ( + + )} + {surfaceLabel} + + + {session.status ? ( + + + {session.status.replace('_', ' ')} + + + ) : null} + + + ); } From 020f83d2c695f08ad35514c98b346b73e3500e21 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 18:52:54 +0100 Subject: [PATCH 19/39] feat: make sessions the primary navigation --- .../layout/CommandPalette.client.test.tsx | 12 +++++------ .../src/components/layout/CommandPalette.tsx | 6 +++--- apps/web/src/components/layout/RouteTitle.tsx | 2 +- .../components/layout/navbar/NavbarDrawer.tsx | 7 ++----- .../layout/navigation-items.test.ts | 8 +++---- .../src/components/layout/navigation-items.ts | 21 +++++-------------- .../components/layout/side-nav/SideNav.tsx | 10 +++------ 7 files changed, 24 insertions(+), 42 deletions(-) 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 bc0d521c5..99ff4caa6 100644 --- a/apps/web/src/components/layout/CommandPalette.tsx +++ b/apps/web/src/components/layout/CommandPalette.tsx @@ -113,8 +113,8 @@ function AuthorizedCommandPalette() { { icon: Plus, label: 'New Task', href: '/' }, { icon: GalleryVerticalEnd, - label: user?.featureFlags?.sessions_ui ? 'Sessions' : 'Tasks', - href: user?.featureFlags?.sessions_ui ? '/sessions' : '/tasks', + label: 'Sessions', + href: '/sessions', }, { icon: Settings, label: 'Settings', href: '/settings' }, { icon: HelpCircle, label: 'Help', action: 'contact-support' }, @@ -137,7 +137,7 @@ function AuthorizedCommandPalette() { ); } return items; - }, [user?.featureFlags?.sessions_ui, user?.isAdmin]); + }, [user?.isAdmin]); // Debounce search input useEffect(() => { 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/navbar/NavbarDrawer.tsx b/apps/web/src/components/layout/navbar/NavbarDrawer.tsx index 066357025..e821b965c 100644 --- a/apps/web/src/components/layout/navbar/NavbarDrawer.tsx +++ b/apps/web/src/components/layout/navbar/NavbarDrawer.tsx @@ -17,11 +17,8 @@ import { getVisiblePrimaryNavItems } from '../navigation-items'; export const NavbarDrawer = () => { const pathname = usePathname(); - const { isAdmin, featureFlags } = useAuthorizedUser(); - const visibleNavItems = getVisiblePrimaryNavItems({ - isAdmin, - sessionsUi: featureFlags?.sessions_ui === true, - }); + const { isAdmin } = useAuthorizedUser(); + const visibleNavItems = getVisiblePrimaryNavItems({ isAdmin }); const [open, setOpen] = useState(false); 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 3436fd698..1df2022dd 100644 --- a/apps/web/src/components/layout/navigation-items.ts +++ b/apps/web/src/components/layout/navigation-items.ts @@ -23,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, @@ -51,17 +51,6 @@ const PRIMARY_NAV_ITEMS: PrimaryNavItem[] = [ export function getVisiblePrimaryNavItems(opts: { isAdmin: boolean; - sessionsUi?: boolean; }): PrimaryNavItem[] { - return PRIMARY_NAV_ITEMS.map((item) => - item.href === '/tasks' && opts.sessionsUi - ? { - ...item, - href: '/sessions', - label: 'Sessions', - description: 'View conversations and delegated work', - matchPaths: ['/sessions', '/tasks', '/cloud-agents'], - } - : item, - ).filter((item) => !item.adminOnly || opts.isAdmin); + return PRIMARY_NAV_ITEMS.filter((item) => !item.adminOnly || opts.isAdmin); } diff --git a/apps/web/src/components/layout/side-nav/SideNav.tsx b/apps/web/src/components/layout/side-nav/SideNav.tsx index 24f1aafc6..de06fdb48 100644 --- a/apps/web/src/components/layout/side-nav/SideNav.tsx +++ b/apps/web/src/components/layout/side-nav/SideNav.tsx @@ -71,7 +71,7 @@ export const SideNav = () => { const pathname = usePathname(); const { setOpen: openCommandPalette } = useCommandPalette(); - const { isAdmin, featureFlags } = useAuthorizedUser(); + const { isAdmin } = useAuthorizedUser(); const hasHydrated = useLayoutStore((state) => state.hasHydrated); const persistedIsSideNavExpanded = useLayoutStore( (state) => state.isSideNavExpanded, @@ -204,12 +204,8 @@ export const SideNav = () => { return Array.from(groups.values()); }, [environmentNameById, nonPinnedQuickAccessTasks]); const visibleNavItems = useMemo( - () => - getVisiblePrimaryNavItems({ - isAdmin, - sessionsUi: featureFlags?.sessions_ui === true, - }), - [featureFlags?.sessions_ui, isAdmin], + () => getVisiblePrimaryNavItems({ isAdmin }), + [isAdmin], ); return ( From a0e470aafdda7389a60e1d32a7a3ea3b90481dc4 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:00:22 +0100 Subject: [PATCH 20/39] feat: list session tasks in side panel --- .../SessionWorkspace.client.test.tsx | 66 +++++++++++++++- .../sessions/[sessionId]/SessionWorkspace.tsx | 78 +++++++++++++++---- 2 files changed, 126 insertions(+), 18 deletions(-) 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 ba2c9fe09..88f74019e 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 @@ -30,6 +30,26 @@ vi.mock('./NestedTaskSidePanel', () => ({ ), })); +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', @@ -63,15 +83,19 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { function renderWorkspace({ isMobile, children =
Session transcript
, + sessionOverride, }: { isMobile: boolean; children?: ReactNode; + sessionOverride?: Partial; }) { useMediaQueryMock.mockReturnValue(!isMobile); render( - {children} + + {children} + , ); } @@ -134,6 +158,46 @@ describe('SessionWorkspace', () => { ).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('opens delegated tasks in the existing session side-panel slot', () => { renderWorkspace({ isMobile: false, children: }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index dd400f38a..2231f329e 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -45,6 +45,7 @@ import { 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; @@ -210,6 +211,39 @@ function SessionTaskPanel({ ); } +function SessionTasksPanel({ + tasks, + onOpenTask, + onClose, +}: { + tasks: SessionTaskSummary[]; + onOpenTask: (taskId: string) => void; + onClose: () => void; +}) { + return ( + + +
+ {tasks.map((task) => ( + + ))} +
+
+ ); +} + function SessionInfoPanel({ session, onClose, @@ -322,6 +356,7 @@ export function SessionWorkspace({ children: ReactNode; }) { const [isInfoOpen, setIsInfoOpen] = useState(false); + const [isTasksOpen, setIsTasksOpen] = useState(false); const [nestedTaskId, setNestedTaskId] = useState(null); const router = useRouter(); const searchParams = useSearchParams(); @@ -330,7 +365,7 @@ export function SessionWorkspace({ (task) => task.taskId === selectedTaskId, ); const panelOpen = - isInfoOpen || Boolean(selectedTask) || Boolean(nestedTaskId); + isInfoOpen || isTasksOpen || Boolean(selectedTask) || Boolean(nestedTaskId); const selectTask = useCallback( (taskId: string | null) => { @@ -344,14 +379,15 @@ export function SessionWorkspace({ ); useEffect(() => { - if (!selectedTaskId && session.tasks.length === 1) { + if (!isTasksOpen && !selectedTaskId && session.tasks.length === 1) { selectTask(session.tasks[0]!.taskId); } - }, [selectTask, selectedTaskId, session.tasks]); + }, [isTasksOpen, selectTask, selectedTaskId, session.tasks]); const openTaskPanel = useCallback( (taskId: string) => { setIsInfoOpen(false); + setIsTasksOpen(false); setNestedTaskId(taskId); selectTask(null); }, @@ -359,6 +395,7 @@ export function SessionWorkspace({ ); const closePanel = () => { setIsInfoOpen(false); + setIsTasksOpen(false); setNestedTaskId(null); selectTask(null); }; @@ -372,6 +409,12 @@ export function SessionWorkspace({ onSelect={selectTask} onClose={closePanel} /> + ) : isTasksOpen ? ( + ) : ( ); @@ -391,25 +434,26 @@ export function SessionWorkspace({ active={isInfoOpen && !selectedTask && !nestedTaskId} icon={Info} onClick={() => { + setIsTasksOpen(false); setNestedTaskId(null); selectTask(null); setIsInfoOpen((previous) => !previous); }} /> - {session.tasks.length ? ( - { - setNestedTaskId(null); - setIsInfoOpen(false); - selectTask(selectedTask ? null : session.tasks[0]!.taskId); - }} - /> - ) : null} + { + setNestedTaskId(null); + setIsInfoOpen(false); + selectTask(null); + setIsTasksOpen((previous) => !previous); + }} + /> {!isSidebarVisible && !panelOpen ? ( From 7f4474256befaa069448bc954138123782b7a969 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:14:00 +0100 Subject: [PATCH 21/39] fix: load tasks for fast session URLs --- .../sessions/[sessionId]/page.test.tsx | 101 ++++++++++++++++-- .../(sandbox)/sessions/[sessionId]/page.tsx | 4 +- apps/web/src/lib/server/sessions.ts | 24 ++++- 3 files changed, 114 insertions(+), 15 deletions(-) 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 fca2933e1..dfcc6574b 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -1,17 +1,25 @@ 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', () => ({ @@ -21,6 +29,9 @@ vi.mock('next/navigation', () => ({ vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, })); +vi.mock('@/trpc/commands/sessions', () => ({ + getSessionByIdCommand: getSessionByIdCommandMock, +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: () => ({ isSidebarVisible: true, @@ -39,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, @@ -165,4 +187,63 @@ describe('Fast session detail page', () => { 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 ffa2e6d9b..6e137dc1f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -31,9 +31,7 @@ export default async function SessionDetailPage({ notFound(); } - const unifiedSession = authorizedUser.featureFlags?.sessions_ui - ? await getSessionByIdCommand(authorizedUser, sessionId) - : null; + const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); const session = unifiedSession?.fastConversationId ? await getFastSessionById( authorizedUser, diff --git a/apps/web/src/lib/server/sessions.ts b/apps/web/src/lib/server/sessions.ts index 3c7cc4644..4f3f6c304 100644 --- a/apps/web/src/lib/server/sessions.ts +++ b/apps/web/src/lib/server/sessions.ts @@ -419,6 +419,24 @@ export async function findAccessibleSession( 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({ @@ -506,10 +524,12 @@ async function getSessionTasks(sessionId: string) { } export async function getSessionById(auth: SessionAuth, sessionId: string) { - const session = await findAccessibleSession(auth, sessionId); + const session = + (await findAccessibleSession(auth, sessionId)) ?? + (await findAccessibleSessionByFastConversationId(auth, sessionId)); if (!session) return null; const [hydrated] = await hydrateSessionRows(auth, [session]); - const sessionTaskDetails = await getSessionTasks(sessionId); + const sessionTaskDetails = await getSessionTasks(session.id); const liveStatus = deriveSessionStatus({ conversationResponding: Boolean(session.fastConversationId) && session.cachedStatus === 'active', From b1f38c51634683da5259de7a968a53aaf3be6d30 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:18:51 +0100 Subject: [PATCH 22/39] fix: label untitled sessions as new --- apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx | 2 +- .../[sessionId]/FastSessionTranscript.client.test.tsx | 4 ++-- .../(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx | 2 +- apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx | 2 +- apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx b/apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx index 76afd2cd7..848b14aa4 100644 --- a/apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx +++ b/apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx @@ -39,7 +39,7 @@ export function FastSessionCard({ const activityDate = new Date(session.updatedAt); const title = session.title ?? - (session.surface === 'web' ? 'Session' : session.conversationId); + (session.surface === 'web' ? 'New session' : session.conversationId); return (
{ , ); - 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 e84fc5666..dffe01ec1 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -64,7 +64,7 @@ export function FastSessionTranscript({ hasOlderMessages, canReply, initialTitle = null, - fallbackTitle = 'Session', + fallbackTitle = 'New session', sessionModel = null, sessionReasoningEffort = null, defaultModelId = 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 dfcc6574b..6726138a0 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -182,7 +182,7 @@ describe('Fast session detail page', () => { sessionId: 'session-2', canReply: true, initialTitle: 'Rotate the API keys', - fallbackTitle: 'Session', + fallbackTitle: 'New session', }), undefined, ); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index 6e137dc1f..5e63f5f4d 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -159,7 +159,7 @@ export default async function SessionDetailPage({ ); const fallbackTitle = getTextFromContentBlocks(initialUserMessage?.contentBlocks ?? [])?.trim() || - 'Session'; + 'New session'; return ( From 6035b167c24c4f16eaf52a1920dce89687dbe373 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:24:23 +0100 Subject: [PATCH 23/39] fix: link delegated fast tasks to sessions --- .../src/server/__tests__/enqueue-task.test.ts | 50 +++++++++++++++++++ .../cloud-agents/src/server/task-run-queue.ts | 27 ++++++---- 2 files changed, 68 insertions(+), 9 deletions(-) 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 b2b0a0ba1..42a876a9c 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -24,6 +24,7 @@ import { TASK_KICKOFF_MESSAGE_SOURCE, ACP_ENVELOPE_EVENT_TYPES, ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, + buildFastAgentChildTaskMetadata, } from '@roomote/types'; import { db, @@ -36,6 +37,7 @@ import { taskPullRequests, taskRunEvents, deploymentSettings, + sessions, users, environments, environmentRepositoryMappings, @@ -63,6 +65,7 @@ import { } from '../task-run-queue'; import { LLM_TITLE_LOCKED_CHECKPOINT } from '../llm-task-title'; import { applyTaskModelSelectionToRun } from '../task-model-selection'; +import { fastAgentConversationRepository } from '../fast-agent/fast-agent-conversation-repository'; const createdTaskIds: string[] = []; const createdUserIds: string[] = []; @@ -962,6 +965,53 @@ describe('enqueueTask Session linkage', () => { db.select().from(sessionTasks).where(eq(sessionTasks.taskId, run.taskId)), ).resolves.toEqual([]); }); + + it('links a delegated Fast task when the general Sessions data rollout is off', async () => { + await db + .update(deploymentSettings) + .set({ metadata: {} }) + .where(eq(deploymentSettings.id, 'default')); + invalidateDeploymentFeatureFlagCache(); + + const userId = await createUser(); + const conversation = { + surface: 'web' as const, + workspaceId: userId, + conversationId: `session-link-${userId}`, + }; + const fastSession = await fastAgentConversationRepository.getOrCreate({ + userId, + conversation, + }); + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'acme/widgets', + description: 'Delegated Fast task', + ...buildFastAgentChildTaskMetadata({ + sessionId: fastSession.id, + conversation, + }), + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'message', + }); + + const [link] = await db + .select() + .from(sessionTasks) + .where(eq(sessionTasks.taskId, run.taskId)); + const [session] = await db + .select({ fastConversationId: sessions.fastConversationId }) + .from(sessions) + .where(eq(sessions.id, link!.sessionId)); + + expect(link?.origin).toBe('fast_delegation'); + expect(session?.fastConversationId).toBe(fastSession.id); + }); }); describe('enqueueTask snapshot resume', () => { diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 5466f6fa7..7b55b373e 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -51,6 +51,7 @@ import { ensureSessionForTask, isChatGptSubscriptionConnected, createTaskWithRetry, + fastAgentConversations, markTaskStartParallelCountEndedAt, projectPendingPrReviewEventsForAssociation, recordTaskStartParallelCount, @@ -1487,6 +1488,19 @@ async function enqueueFreshLaunch( }); const repositoryName = taskWithHarnessOverrides.payload.repo || null; + const fastParent = getFastAgentParentFromPayload( + taskWithHarnessOverrides.payload, + ); + const hasFastParent = fastParent + ? await db.query.fastAgentConversations.findFirst({ + where: eq(fastAgentConversations.id, fastParent.sessionId), + columns: { id: true }, + }) + : null; + const fastConversationId = hasFastParent ? fastParent?.sessionId : null; + // Fast task cards are part of the primary Sessions UI, so their relationship + // must be available immediately even while the general data rollout is off. + const shouldLinkSession = sessionsDataEnabled || fastConversationId !== null; const resolvedTaskPolicy = resolveTaskRuntimePolicy({ taskType: taskWithHarnessOverrides.type, launchClass: @@ -1645,12 +1659,10 @@ async function enqueueFreshLaunch( }); if (activeRun) { - if (sessionsDataEnabled) { + if (shouldLinkSession) { await ensureSessionForTask(tx, { taskId: existingTask.id, - fastConversationId: - getFastAgentParentFromPayload(taskWithHarnessOverrides.payload) - ?.sessionId ?? null, + fastConversationId, origin: 'follow_up', existingTaskReused: true, }); @@ -1704,13 +1716,10 @@ async function enqueueFreshLaunch( taskId = createdTask.id; } - if (sessionsDataEnabled) { - const fastParent = getFastAgentParentFromPayload( - taskWithHarnessOverrides.payload, - ); + if (shouldLinkSession) { await ensureSessionForTask(tx, { taskId, - fastConversationId: fastParent?.sessionId ?? null, + fastConversationId, origin: fastParent ? 'fast_delegation' : existingTask From a9e86926cdc4f9f4f8bf04dc6005f852338a30b9 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:27:37 +0100 Subject: [PATCH 24/39] Revert "fix: link delegated fast tasks to sessions" This reverts commit 6035b167c24c4f16eaf52a1920dce89687dbe373. --- .../src/server/__tests__/enqueue-task.test.ts | 50 ------------------- .../cloud-agents/src/server/task-run-queue.ts | 27 ++++------ 2 files changed, 9 insertions(+), 68 deletions(-) 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 42a876a9c..b2b0a0ba1 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -24,7 +24,6 @@ import { TASK_KICKOFF_MESSAGE_SOURCE, ACP_ENVELOPE_EVENT_TYPES, ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, - buildFastAgentChildTaskMetadata, } from '@roomote/types'; import { db, @@ -37,7 +36,6 @@ import { taskPullRequests, taskRunEvents, deploymentSettings, - sessions, users, environments, environmentRepositoryMappings, @@ -65,7 +63,6 @@ import { } from '../task-run-queue'; import { LLM_TITLE_LOCKED_CHECKPOINT } from '../llm-task-title'; import { applyTaskModelSelectionToRun } from '../task-model-selection'; -import { fastAgentConversationRepository } from '../fast-agent/fast-agent-conversation-repository'; const createdTaskIds: string[] = []; const createdUserIds: string[] = []; @@ -965,53 +962,6 @@ describe('enqueueTask Session linkage', () => { db.select().from(sessionTasks).where(eq(sessionTasks.taskId, run.taskId)), ).resolves.toEqual([]); }); - - it('links a delegated Fast task when the general Sessions data rollout is off', async () => { - await db - .update(deploymentSettings) - .set({ metadata: {} }) - .where(eq(deploymentSettings.id, 'default')); - invalidateDeploymentFeatureFlagCache(); - - const userId = await createUser(); - const conversation = { - surface: 'web' as const, - workspaceId: userId, - conversationId: `session-link-${userId}`, - }; - const fastSession = await fastAgentConversationRepository.getOrCreate({ - userId, - conversation, - }); - const run = await launchFresh({ - task: standardTaskInput({ - payload: { - repo: 'acme/widgets', - description: 'Delegated Fast task', - ...buildFastAgentChildTaskMetadata({ - sessionId: fastSession.id, - conversation, - }), - }, - }), - initiator: { kind: 'user', userId }, - workflow: 'standard', - surface: 'web', - trigger: 'message', - }); - - const [link] = await db - .select() - .from(sessionTasks) - .where(eq(sessionTasks.taskId, run.taskId)); - const [session] = await db - .select({ fastConversationId: sessions.fastConversationId }) - .from(sessions) - .where(eq(sessions.id, link!.sessionId)); - - expect(link?.origin).toBe('fast_delegation'); - expect(session?.fastConversationId).toBe(fastSession.id); - }); }); describe('enqueueTask snapshot resume', () => { diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 7b55b373e..5466f6fa7 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -51,7 +51,6 @@ import { ensureSessionForTask, isChatGptSubscriptionConnected, createTaskWithRetry, - fastAgentConversations, markTaskStartParallelCountEndedAt, projectPendingPrReviewEventsForAssociation, recordTaskStartParallelCount, @@ -1488,19 +1487,6 @@ async function enqueueFreshLaunch( }); const repositoryName = taskWithHarnessOverrides.payload.repo || null; - const fastParent = getFastAgentParentFromPayload( - taskWithHarnessOverrides.payload, - ); - const hasFastParent = fastParent - ? await db.query.fastAgentConversations.findFirst({ - where: eq(fastAgentConversations.id, fastParent.sessionId), - columns: { id: true }, - }) - : null; - const fastConversationId = hasFastParent ? fastParent?.sessionId : null; - // Fast task cards are part of the primary Sessions UI, so their relationship - // must be available immediately even while the general data rollout is off. - const shouldLinkSession = sessionsDataEnabled || fastConversationId !== null; const resolvedTaskPolicy = resolveTaskRuntimePolicy({ taskType: taskWithHarnessOverrides.type, launchClass: @@ -1659,10 +1645,12 @@ async function enqueueFreshLaunch( }); if (activeRun) { - if (shouldLinkSession) { + if (sessionsDataEnabled) { await ensureSessionForTask(tx, { taskId: existingTask.id, - fastConversationId, + fastConversationId: + getFastAgentParentFromPayload(taskWithHarnessOverrides.payload) + ?.sessionId ?? null, origin: 'follow_up', existingTaskReused: true, }); @@ -1716,10 +1704,13 @@ async function enqueueFreshLaunch( taskId = createdTask.id; } - if (shouldLinkSession) { + if (sessionsDataEnabled) { + const fastParent = getFastAgentParentFromPayload( + taskWithHarnessOverrides.payload, + ); await ensureSessionForTask(tx, { taskId, - fastConversationId, + fastConversationId: fastParent?.sessionId ?? null, origin: fastParent ? 'fast_delegation' : existingTask From beb7796f50ce760787acaa83b4a4feb420d38f5a Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:30:42 +0100 Subject: [PATCH 25/39] fix: refresh session task sidebar --- .../SessionWorkspace.client.test.tsx | 74 +++++++++++++++++-- .../sessions/[sessionId]/SessionWorkspace.tsx | 20 ++++- 2 files changed, 83 insertions(+), 11 deletions(-) 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 88f74019e..1e3739462 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,12 +1,14 @@ 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(() => ({ +const { useMediaQueryMock, sessionQueryState } = vi.hoisted(() => ({ useMediaQueryMock: vi.fn(), + sessionQueryState: { data: null as unknown }, })); vi.mock('usehooks-ts', () => ({ @@ -24,6 +26,23 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sessions: { + byId: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['sessions', 'byId', input.sessionId], + queryFn: async () => sessionQueryState.data, + ...options, + }), + }, + }, + }), +})); + vi.mock('./NestedTaskSidePanel', () => ({ NestedTaskSidePanel: ({ taskId }: { taskId: string }) => (
Nested panel {taskId}
@@ -84,19 +103,29 @@ function renderWorkspace({ isMobile, children =
Session transcript
, sessionOverride, + queriedTasks, }: { isMobile: boolean; children?: ReactNode; sessionOverride?: Partial; + queriedTasks?: SessionInfo['tasks']; }) { useMediaQueryMock.mockReturnValue(!isMobile); + const initialSession = { ...session, ...sessionOverride }; + sessionQueryState.data = { + ...initialSession, + tasks: queriedTasks ?? initialSession.tasks, + }; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - - - {children} - - , + + + {children} + + , ); } @@ -198,6 +227,37 @@ describe('SessionWorkspace', () => { 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, + queriedTasks: [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: }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 2231f329e..e0dd83e6f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -3,10 +3,12 @@ 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 { useTRPC } from '@/trpc/client'; import { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { @@ -358,10 +360,20 @@ export function SessionWorkspace({ 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 { data: currentSession } = useQuery( + trpc.sessions.byId.queryOptions( + { sessionId: session.id }, + { + refetchInterval: 2_000, + }, + ), + ); + const sessionTasks = currentSession?.tasks ?? session.tasks; const selectedTaskId = searchParams.get('task'); - const selectedTask = session.tasks.find( + const selectedTask = sessionTasks.find( (task) => task.taskId === selectedTaskId, ); const panelOpen = @@ -405,13 +417,13 @@ export function SessionWorkspace({ ) : isTasksOpen ? ( @@ -445,7 +457,7 @@ export function SessionWorkspace({ label="Tasks" tooltip="Tasks" active={isTasksOpen} - disabled={session.tasks.length === 0} + disabled={sessionTasks.length === 0} icon={Rows4} onClick={() => { setNestedTaskId(null); From d8efdac81b2935dd1a7cf83a78e7adeca2ffdbef Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 19:38:05 +0100 Subject: [PATCH 26/39] fix: list tasks for fast sessions --- .../SessionWorkspace.client.test.tsx | 31 ++++++++-- .../sessions/[sessionId]/SessionWorkspace.tsx | 22 ++++++- .../(sandbox)/sessions/[sessionId]/page.tsx | 7 ++- apps/web/src/lib/server/fast-sessions.test.ts | 28 +++++++++ apps/web/src/lib/server/fast-sessions.ts | 59 +++++++++++++++++++ .../src/trpc/commands/fast-sessions/index.ts | 12 +++- apps/web/src/trpc/routers/_app.ts | 6 ++ 7 files changed, 155 insertions(+), 10 deletions(-) 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 1e3739462..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 @@ -6,10 +6,13 @@ import { SandboxLayoutContext } from '../../use-sandbox-layout'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; import { useOpenSessionTaskPanel } from './session-task-panel-context'; -const { useMediaQueryMock, sessionQueryState } = vi.hoisted(() => ({ - useMediaQueryMock: vi.fn(), - sessionQueryState: { data: null as unknown }, -})); +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, @@ -40,6 +43,18 @@ vi.mock('@/trpc/client', () => ({ }), }, }, + fastSessions: { + tasks: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['fastSessions', 'tasks', input.sessionId], + queryFn: async () => fastTaskQueryState.data, + ...options, + }), + }, + }, }), })); @@ -104,11 +119,15 @@ function renderWorkspace({ 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 }; @@ -116,6 +135,7 @@ function renderWorkspace({ ...initialSession, tasks: queriedTasks ?? initialSession.tasks, }; + fastTaskQueryState.data = queriedFastTasks ?? initialSession.taskCards ?? []; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -243,7 +263,8 @@ describe('SessionWorkspace', () => { }; renderWorkspace({ isMobile: false, - queriedTasks: [delegatedTask], + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [delegatedTask], }); await waitFor(() => { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index e0dd83e6f..ddf049d09 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -62,6 +62,8 @@ export type SessionInfo = { createdAt: Date; status: string | null; tasks: SessionTaskSummary[]; + taskSource?: 'unified' | 'fast'; + taskCards?: Array>; }; const SURFACE_LABELS: Record = { @@ -218,7 +220,7 @@ function SessionTasksPanel({ onOpenTask, onClose, }: { - tasks: SessionTaskSummary[]; + tasks: Array>; onOpenTask: (taskId: string) => void; onClose: () => void; }) { @@ -363,15 +365,29 @@ export function SessionWorkspace({ 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, @@ -423,7 +439,7 @@ export function SessionWorkspace({ /> ) : isTasksOpen ? ( @@ -457,7 +473,7 @@ export function SessionWorkspace({ label="Tasks" tooltip="Tasks" active={isTasksOpen} - disabled={sessionTasks.length === 0} + disabled={taskCards.length === 0} icon={Rows4} onClick={() => { setNestedTaskId(null); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index 5e63f5f4d..dc9d0e2f7 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -8,7 +8,10 @@ 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'; @@ -153,6 +156,8 @@ export default async function SessionDetailPage({ createdAt: session.createdAt, status: null, tasks: [], + taskSource: 'fast', + taskCards: (await getFastSessionTasks(authorizedUser, session.id)) ?? [], }; const initialUserMessage = session.messages.find( (message) => message.role === 'user', 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/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index a6d62e524..7461a75ea 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -26,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 @@ -205,6 +208,13 @@ export async function startFastSessionCommand( }; } +export async function getFastSessionTasksCommand( + auth: UserAuthSuccess, + sessionId: string, +) { + return getFastSessionTasks(auth, sessionId); +} + export async function replyToFastSessionCommand( auth: UserAuthSuccess, input: { diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 5f7208343..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'; @@ -2822,6 +2823,11 @@ 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({ From 566bfeb2a6a382bca764db2de9f93a72dd441866 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Fri, 28 Aug 2026 10:11:34 +0100 Subject: [PATCH 27/39] feat: enable fast communication mode by default --- .../api/src/handlers/fast-agent-entry.test.ts | 12 ++++++++- apps/api/src/handlers/fast-agent-entry.ts | 4 +-- .../slack/helpers/user-mapping.test.ts | 27 +++++++++++++++++++ .../handlers/slack/helpers/user-mapping.ts | 5 ++-- apps/docs/personal-settings.mdx | 11 ++++---- .../docs/providers/communications/discord.mdx | 6 ++--- apps/docs/providers/communications/slack.mdx | 7 ++--- .../[sessionId]/FastSessionTranscript.tsx | 2 +- apps/web/src/app/layout.tsx | 7 ++--- .../src/components/layout/navigation-items.ts | 2 +- .../usePersonalPreferences.client.test.tsx | 2 +- .../src/trpc/commands/preferences/index.ts | 4 ++- .../preferences/personal-preferences.test.ts | 19 ++++++++++++- apps/web/src/types/preferences.ts | 2 +- ...64_enable_fast_mode_for_existing_users.sql | 5 ++++ packages/db/drizzle/meta/_journal.json | 7 +++++ 16 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 packages/db/drizzle/0064_enable_fast_mode_for_existing_users.sql diff --git a/apps/api/src/handlers/fast-agent-entry.test.ts b/apps/api/src/handlers/fast-agent-entry.test.ts index a8d9c61f4..bc96b3ff4 100644 --- a/apps/api/src/handlers/fast-agent-entry.test.ts +++ b/apps/api/src/handlers/fast-agent-entry.test.ts @@ -25,9 +25,19 @@ describe('hasCommunicationsFastModeDefault', () => { ); }); - it('returns false when the stored preference is not enabled', async () => { + it('defaults to enabled when no preference is stored', async () => { mocks.findUser.mockResolvedValue({ metadata: {} }); + await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( + true, + ); + }); + + it('honors an explicit opt-out', async () => { + mocks.findUser.mockResolvedValue({ + metadata: { communications_fast_mode_default: false }, + }); + await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( false, ); diff --git a/apps/api/src/handlers/fast-agent-entry.ts b/apps/api/src/handlers/fast-agent-entry.ts index a87bb5f88..02e4cb83b 100644 --- a/apps/api/src/handlers/fast-agent-entry.ts +++ b/apps/api/src/handlers/fast-agent-entry.ts @@ -22,11 +22,11 @@ export async function hasCommunicationsFastModeDefault( }); const metadata = user?.metadata; - return ( + return !( typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata) && (metadata as Record).communications_fast_mode_default === - true + false ); } diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index f339fbb5b..7c5bcfbb4 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -74,6 +74,33 @@ describe('lookupSlackUserMapping', () => { }); }); + it('enables Fast mode by default for active mappings without a preference', async () => { + const createdAt = new Date('2024-01-01T00:00:00.000Z'); + const updatedAt = new Date('2024-01-02T00:00:00.000Z'); + limitMock.mockResolvedValueOnce([ + { + id: 'mapping-1', + slackUserId: 'U123', + slackTeamId: 'T123', + userId: 'user-1', + createdAt, + updatedAt, + matchedUserId: 'user-1', + userDeletedAt: null, + userMetadata: {}, + }, + ]); + + const { lookupSlackUserMapping } = await import('./user-mapping.js'); + + await expect( + lookupSlackUserMapping({ slackUserId: 'U123', teamId: 'T123' }), + ).resolves.toMatchObject({ + activeMapping: { communicationsFastModeDefault: true }, + hasInactiveMapping: false, + }); + }); + it('flags stale mappings whose linked user was removed', async () => { limitMock.mockResolvedValueOnce([ { diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 5537804bc..7f32c8900 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -62,12 +62,13 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, - communicationsFastModeDefault: + communicationsFastModeDefault: !( typeof row.userMetadata === 'object' && row.userMetadata !== null && !Array.isArray(row.userMetadata) && (row.userMetadata as Record) - .communications_fast_mode_default === true, + .communications_fast_mode_default === false + ), }, hasInactiveMapping: false, }; diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 02f976dee..ad3bd3bfd 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -64,11 +64,12 @@ Personal Settings also include app preferences such as: - **Mind Reader Mode** to expand LLM thoughts by default in task conversations; you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view -- **Fast response mode** to select Fast by default for new homepage prompts and - use Fast responses by default for messages sent from your linked Slack and - Discord accounts. An explicit homepage workspace choice takes precedence. - The chat preference does not apply to GitHub, Teams, or Telegram. You can - still use `!fast` explicitly in Slack whether the preference is on or off. +- **Fast response mode** is enabled by default for new homepage prompts and + messages sent from your linked Slack and Discord accounts. Turn it off here + if you prefer task execution by default. An explicit homepage workspace + choice takes precedence. The chat preference does not apply to GitHub, Teams, + or Telegram. You can still use `!fast` explicitly in Slack whether the + preference is on or off. Most teammates only need profile, linked accounts, and theme settings. diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 3c8b773ec..3be20c006 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -120,9 +120,9 @@ under **Settings > Automations**, the same way you would pick a Slack channel. current one - use `/goal objective:` to keep working toward an objective across multiple turns in an active task thread or DM; this does not create a new task -- enable **Fast response mode** under **Settings > Personal** to send ordinary - Discord DMs, mentions, and eligible thread replies from your linked account - through the fast orchestrator +- **Fast response mode** is enabled by default for ordinary Discord DMs, + mentions, and eligible thread replies from your linked account; turn it off + under **Settings > Personal** to start tasks by default instead - when Roomote asks where to run a task, use a button or reply naturally in the same thread or DM; `yes`, `never mind`, and `use API instead` confirm, cancel, or revise the pending route diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 113c832c0..a71da3221 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -181,9 +181,10 @@ Mention the app and use `!fast ` to ask the fast orchestrator a question or delegate work into a task. For example: `@Roomote !fast summarize this thread` or `@Roomote !fast fix the failing CI job`. -Enable **Fast response mode** under **Settings > Personal** to send ordinary -messages from your linked Slack and Discord accounts through the fast -orchestrator without an explicit command. +**Fast response mode** is enabled by default. Turn it off under +**Settings > Personal** if you want ordinary messages from your linked Slack +and Discord accounts to start tasks instead of going through the fast +orchestrator. You can still use `!fast` for an explicit Fast request. Fast can read a bounded history from the current Slack channel, use MCP servers and user-scoped integrations that you are allowed to access, and delegate diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index dffe01ec1..27e4123eb 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -270,7 +270,7 @@ export function FastSessionTranscript({ return (

diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 60b6c110b..6361bec27 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next'; import localFont from 'next/font/local'; import { DM_Sans } from 'next/font/google'; +import Script from 'next/script'; import { PRODUCT_NAME } from '@roomote/types'; @@ -117,12 +118,12 @@ export default async function RootLayout({ return ( - - { colorTheme: 'system', mindReaderMode: false, narrationMode: false, - communicationsFastModeDefault: false, + communicationsFastModeDefault: true, }); }); diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index 2865fee0b..c6ece35a2 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -37,7 +37,9 @@ function normalizePersonalPreferences( ? metadata.narration_mode : DEFAULT_PERSONAL_PREFERENCES.narrationMode, communicationsFastModeDefault: - metadata.communications_fast_mode_default === true, + typeof metadata.communications_fast_mode_default === 'boolean' + ? metadata.communications_fast_mode_default + : DEFAULT_PERSONAL_PREFERENCES.communicationsFastModeDefault, }; } diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts index 6d0aefd95..747d19157 100644 --- a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -17,7 +17,12 @@ describe('personal preferences', () => { await expect( getPersonalPreferencesCommand(buildAuth(user.id)), - ).resolves.toEqual(expect.objectContaining({ mindReaderMode: false })); + ).resolves.toEqual( + expect.objectContaining({ + mindReaderMode: false, + communicationsFastModeDefault: true, + }), + ); }); it('persists mind reader mode without replacing other metadata', async () => { @@ -93,4 +98,16 @@ describe('personal preferences', () => { expect.objectContaining({ communicationsFastModeDefault: true }), ); }); + + it('honors an explicit communications fast mode opt-out', async () => { + const user = await userFactory.create({ + metadata: { communications_fast_mode_default: false }, + }); + + await expect( + getPersonalPreferencesCommand(buildAuth(user.id)), + ).resolves.toEqual( + expect.objectContaining({ communicationsFastModeDefault: false }), + ); + }); }); diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index 60dd3a4f0..2ce9cecd1 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -25,5 +25,5 @@ export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', mindReaderMode: false, narrationMode: false, - communicationsFastModeDefault: false, + communicationsFastModeDefault: true, }; diff --git a/packages/db/drizzle/0064_enable_fast_mode_for_existing_users.sql b/packages/db/drizzle/0064_enable_fast_mode_for_existing_users.sql new file mode 100644 index 000000000..edba2a2c7 --- /dev/null +++ b/packages/db/drizzle/0064_enable_fast_mode_for_existing_users.sql @@ -0,0 +1,5 @@ +UPDATE "users" +SET + "metadata" = "metadata" || '{"communications_fast_mode_default": true}'::jsonb, + "updated_at" = now() +WHERE "metadata" -> 'communications_fast_mode_default' IS DISTINCT FROM 'true'::jsonb; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index bef859353..afb270b82 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -456,6 +456,13 @@ "when": 1787908038379, "tag": "0064_deep_vengeance", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1787850305557, + "tag": "0064_enable_fast_mode_for_existing_users", + "breakpoints": true } ] } From ac212cd1f3370edeca0ffc955f022d78b7ca3401 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 09:14:00 +0000 Subject: [PATCH 28/39] fix: gate session UI during data rollout --- .../sessions/[sessionId]/page.test.tsx | 21 +++++++++++++--- .../(sandbox)/sessions/[sessionId]/page.tsx | 5 +++- .../task/[taskId]/Header.client.test.tsx | 22 +++++++++++++++- .../app/(sandbox)/task/[taskId]/Header.tsx | 25 ++++++++++++------- 4 files changed, 58 insertions(+), 15 deletions(-) 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 c180096b2..330b357cb 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -4,12 +4,14 @@ import { renderToStaticMarkup } from 'react-dom/server'; const { authorizeMock, getFastSessionByIdMock, + getFastSessionTasksMock, getSessionByIdCommandMock, transcriptMock, sessionWorkspaceMock, } = vi.hoisted(() => ({ authorizeMock: vi.fn(), getFastSessionByIdMock: vi.fn(), + getFastSessionTasksMock: vi.fn(), getSessionByIdCommandMock: vi.fn(), transcriptMock: vi.fn( ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( @@ -28,6 +30,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, + getFastSessionTasks: getFastSessionTasksMock, })); vi.mock('@/trpc/commands/sessions', () => ({ getSessionByIdCommand: getSessionByIdCommandMock, @@ -64,6 +67,7 @@ describe('Fast session detail page', () => { beforeEach(() => { vi.clearAllMocks(); getSessionByIdCommandMock.mockResolvedValue(null); + getFastSessionTasksMock.mockResolvedValue([]); }); it('uses the shared task workspace and renders supported session data', async () => { @@ -189,7 +193,7 @@ describe('Fast session detail page', () => { ); }); - it('loads linked tasks for Fast session URLs when Sessions UI is disabled', async () => { + it('keeps the legacy Fast detail path when Sessions UI is disabled', async () => { authorizeMock.mockResolvedValue({ success: true, userId: 'user-1', @@ -226,6 +230,9 @@ describe('Fast session detail page', () => { messages: [], hasOlderMessages: false, }); + getFastSessionTasksMock.mockResolvedValue([ + { taskId: 'task-1', title: 'Delegated task' }, + ]); renderToStaticMarkup( await SessionDetailPage({ @@ -233,15 +240,21 @@ describe('Fast session detail page', () => { }), ); - expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect(getSessionByIdCommandMock).not.toHaveBeenCalled(); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'fast-session-3', + ); + expect(getFastSessionTasksMock).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' })], + id: 'fast-session-3', + taskSource: 'fast', + taskCards: [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 dc9d0e2f7..be0d4b12d 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -34,7 +34,10 @@ export default async function SessionDetailPage({ notFound(); } - const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); + const sessionsUiEnabled = authorizedUser.featureFlags?.sessions_ui === true; + const unifiedSession = sessionsUiEnabled + ? await getSessionByIdCommand(authorizedUser, sessionId) + : null; const session = unifiedSession?.fastConversationId ? await getFastSessionById( authorizedUser, 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 78cde02ff..d1abf0773 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 @@ -6,11 +6,13 @@ const { useTRPCMock, updateTitleMutationMock, parentSessionQueryMock, + featureFlagState, } = vi.hoisted(() => ({ useSandboxLayoutMock: vi.fn(), useTRPCMock: vi.fn(), updateTitleMutationMock: vi.fn(async () => undefined), parentSessionQueryMock: vi.fn(), + featureFlagState: { sessionsUiEnabled: false }, })); vi.mock('../../use-sandbox-layout', () => ({ @@ -21,6 +23,12 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ + featureFlags: { sessions_ui: featureFlagState.sessionsUiEnabled }, + }), +})); + vi.mock('./TaskSessionReadTracker', () => ({ TaskSessionReadTracker: () => null, })); @@ -87,6 +95,7 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + featureFlagState.sessionsUiEnabled = false; parentSessionQueryMock.mockResolvedValue({ sessionId: 'session-1', title: 'Parent Session', @@ -167,7 +176,17 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); - it('renders the parent session link regardless of the Sessions UI flag', async () => { + it('does not query or render Session links while Sessions UI is disabled', () => { + renderHeader(); + + expect(parentSessionQueryMock).not.toHaveBeenCalled(); + expect(screen.queryByRole('link', { name: 'Parent Session' })).toBeNull(); + expect(screen.queryByRole('link', { name: /Go to session/ })).toBeNull(); + }); + + it('renders the parent session link while Sessions UI is enabled', async () => { + featureFlagState.sessionsUiEnabled = true; + renderHeader(); expect( @@ -180,6 +199,7 @@ describe('Header', () => { }); it('links to the Fast session when the task has no unified session', async () => { + featureFlagState.sessionsUiEnabled = true; parentSessionQueryMock.mockResolvedValue(null); renderHeader({ diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 749a07a0a..5c68b7c53 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -26,6 +26,7 @@ import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; import { useTRPC } from '@/trpc/client'; +import { useAuthorizedUser } from '@/hooks/useUser'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; @@ -38,29 +39,35 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); + const { featureFlags } = useAuthorizedUser(); + const sessionsUiEnabled = featureFlags?.sessions_ui === true; const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); - const parentSessionOptions = trpc.sessions?.forTask?.queryOptions({ - taskId, - }) ?? { + const parentSessionOptions = trpc.sessions?.forTask?.queryOptions( + { taskId }, + { enabled: sessionsUiEnabled }, + ) ?? { queryKey: ['sessions', 'for-task', 'disabled', taskId], queryFn: async () => null, enabled: false, }; - const { data: parentSession } = useQuery(parentSessionOptions); + const { data: queriedParentSession } = useQuery(parentSessionOptions); + const parentSession = sessionsUiEnabled ? queriedParentSession : null; 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 sessionHref = sessionsUiEnabled + ? parentSession + ? `/sessions/${parentSession.sessionId}?task=${taskId}` + : taskRun?.payload?.fastAgentSessionId + ? `/sessions/${taskRun.payload.fastAgentSessionId}` + : null + : null; const badges = [ (environmentId || repo) && ( From c639c1550b054e1f288dd4c5ac44c35837e5872b Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 09:15:49 +0000 Subject: [PATCH 29/39] fix: sequence concurrent database migrations --- ...users.sql => 0065_enable_fast_mode_for_existing_users.sql} | 0 packages/db/drizzle/meta/_journal.json | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/db/drizzle/{0064_enable_fast_mode_for_existing_users.sql => 0065_enable_fast_mode_for_existing_users.sql} (100%) diff --git a/packages/db/drizzle/0064_enable_fast_mode_for_existing_users.sql b/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql similarity index 100% rename from packages/db/drizzle/0064_enable_fast_mode_for_existing_users.sql rename to packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index afb270b82..dc2585f58 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -458,10 +458,10 @@ "breakpoints": true }, { - "idx": 64, + "idx": 65, "version": "7", "when": 1787850305557, - "tag": "0064_enable_fast_mode_for_existing_users", + "tag": "0065_enable_fast_mode_for_existing_users", "breakpoints": true } ] From 02b13d205b07357e0557fb5452a98db362e1d2e6 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 09:25:37 +0000 Subject: [PATCH 30/39] fix: preserve explicit Fast mode opt-outs --- .../db/drizzle/0065_enable_fast_mode_for_existing_users.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql b/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql index edba2a2c7..6e7a31327 100644 --- a/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql +++ b/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql @@ -2,4 +2,4 @@ UPDATE "users" SET "metadata" = "metadata" || '{"communications_fast_mode_default": true}'::jsonb, "updated_at" = now() -WHERE "metadata" -> 'communications_fast_mode_default' IS DISTINCT FROM 'true'::jsonb; +WHERE NOT ("metadata" ? 'communications_fast_mode_default'); From a3e67ba0c876a243e4514a3d808862bc5c4c5db1 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 09:40:51 +0000 Subject: [PATCH 31/39] test: align navigation expectations with Sessions --- .../components/layout/navbar/NavbarDrawer.client.test.tsx | 2 +- .../src/components/layout/side-nav/SideNav.client.test.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx b/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx index 551460a58..0370fc9c6 100644 --- a/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx +++ b/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx @@ -96,7 +96,7 @@ describe('NavbarDrawer', () => { .getAllByRole('link') .map((link) => link.textContent?.trim()) .filter(Boolean), - ).toEqual(['Home', 'Tasks', 'Automations', 'Analytics', 'Settings']); + ).toEqual(['Home', 'Sessions', 'Automations', 'Analytics', 'Settings']); expect( screen.queryByRole('button', { name: /support/i }), ).not.toBeInTheDocument(); diff --git a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx index 34e2aa256..5a78bfa45 100644 --- a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx +++ b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx @@ -505,13 +505,13 @@ describe('SideNav quick access tasks', () => { expect(screen.getByTestId('nav-/analytics')).toBeInTheDocument(); }); - it('shows task history before automations for admins', () => { + it('shows Sessions before automations for admins', () => { render(); const automations = screen.getByTestId('nav-/automations'); - const tasks = screen.getByTestId('nav-/tasks'); + const sessions = screen.getByTestId('nav-/sessions'); - expect(automations.compareDocumentPosition(tasks)).toBe( + expect(automations.compareDocumentPosition(sessions)).toBe( Node.DOCUMENT_POSITION_PRECEDING, ); }); From f08c61a446c340b6e43581a351281b5d36f7637c Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 09:46:28 +0000 Subject: [PATCH 32/39] fix: keep migration journal timestamps monotonic --- packages/db/drizzle/meta/_journal.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index dc2585f58..368455f41 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -460,7 +460,7 @@ { "idx": 65, "version": "7", - "when": 1787850305557, + "when": 1787908038380, "tag": "0065_enable_fast_mode_for_existing_users", "breakpoints": true } From 27188c4b18d22d3bcca8296423772b2728c17d3b Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:53:31 -0400 Subject: [PATCH 33/39] Address review findings; make fast mode and Sessions unconditional - Fast mode is always on: remove the communications_fast_mode_default preference, its settings toggle, and the enable-migration - Remove the sessions_data/sessions_ui/sessions_comms feature flags; Sessions is unconditionally the primary workspace. /tasks stays fully functional but unlinked from primary nav; the legacy fast-sessions list and the dead home auto-routing path are deleted - Persist fast-conversation responding state as a TTL lease (sessions.responding_until) so status recomputation cannot flip a responding session to ready; skip no-op session writes in the reconcile loop and adopt orphan fast conversations in steady state - Only transcript-visible messages count toward unread, matching what the read cursor can reach; sync generated conversation titles onto placeholder session titles; archive sessions emptied by task deletion - Fix the single-task session side panel that could not be closed (panel state is now a discriminated union with a mount-only default); restore the pre-paint theme boot script; honor the server's stop-polling signal in delegated task cards; batch per-task session queries; resolve markRead cursors server-side instead of fetching timelines - Align session analytics with task/cost analytics: day-aligned cutoffs, mapped source labels, formatted dates, grouped execution counts - Share session status values/badges, surface labels/icons, and inference-cost formatting through single modules --- .../__tests__/channel-auto-start.test.ts | 57 +- .../handlers/discord/__tests__/index.test.ts | 103 +- .../handlers/discord/channel-auto-start.ts | 6 +- apps/api/src/handlers/discord/index.ts | 8 +- .../api/src/handlers/fast-agent-entry.test.ts | 45 - apps/api/src/handlers/fast-agent-entry.ts | 20 - .../channel-auto-start-unlinked.test.ts | 1 - .../handlers/slack/events/message-entry.ts | 12 +- .../slack/helpers/user-mapping.test.ts | 31 - .../handlers/slack/helpers/user-mapping.ts | 12 +- apps/bullmq/package.json | 1 - .../__tests__/sessions-reconcile.test.ts | 45 +- .../src/scheduled-jobs/sessions-reconcile.ts | 35 +- apps/docs/personal-settings.mdx | 6 - .../docs/providers/communications/discord.mdx | 6 +- apps/docs/providers/communications/slack.mdx | 7 +- apps/docs/tasks.mdx | 5 +- .../(authenticated)/home/Home.client.test.tsx | 407 +- .../web/src/app/(authenticated)/home/Home.tsx | 250 +- .../sessions/FastSessionCard.tsx | 102 - .../(authenticated)/sessions/SessionCard.tsx | 21 +- .../sessions/SessionsFilters.tsx | 230 +- .../src/app/(authenticated)/sessions/page.tsx | 220 +- .../src/app/(authenticated)/tasks/page.tsx | 28 +- .../[sessionId]/SessionReadTracker.tsx | 25 +- .../sessions/[sessionId]/SessionTaskCards.tsx | 3 +- .../sessions/[sessionId]/SessionWorkspace.tsx | 168 +- .../sessions/[sessionId]/page.test.tsx | 105 +- .../(sandbox)/sessions/[sessionId]/page.tsx | 26 +- .../task/[taskId]/Header.client.test.tsx | 23 +- .../app/(sandbox)/task/[taskId]/Header.tsx | 28 +- .../task/[taskId]/TaskSessionReadTracker.tsx | 23 +- .../acp/DelegatedTaskCard.client.test.tsx | 8 +- .../messages/acp/DelegatedTaskCard.tsx | 4 +- apps/web/src/app/layout.tsx | 10 +- .../layout/CommandPalette.client.test.tsx | 9 + .../src/components/layout/CommandPalette.tsx | 10 +- .../sessions/SessionStatusBadge.tsx | 25 + .../components/sessions/session-surfaces.ts | 49 + .../settings/UserPreferencesSection.test.tsx | 81 +- .../settings/UserPreferencesSection.tsx | 33 - apps/web/src/hooks/task-runs/index.ts | 1 - .../src/hooks/task-runs/useRouteHomeTask.ts | 31 - apps/web/src/hooks/useMarkSessionRead.ts | 28 + .../usePersonalPreferences.client.test.tsx | 1 - apps/web/src/hooks/usePersonalPreferences.ts | 10 - apps/web/src/hooks/useRecentSessions.ts | 52 +- .../lib/server/analytics/session-rows.test.ts | 146 + .../src/lib/server/analytics/session-rows.ts | 36 +- apps/web/src/lib/server/auth-context.test.ts | 10 +- apps/web/src/lib/server/fast-sessions.test.ts | 96 +- apps/web/src/lib/server/fast-sessions.ts | 87 +- apps/web/src/lib/server/sessions.test.ts | 62 + apps/web/src/lib/server/sessions.ts | 224 +- .../src/trpc/commands/fast-sessions/index.ts | 4 +- .../trpc/commands/feature-flags/index.test.ts | 28 +- .../src/trpc/commands/preferences/index.ts | 9 - .../preferences/personal-preferences.test.ts | 46 - apps/web/src/trpc/commands/sessions/index.ts | 32 +- .../src/trpc/commands/task-runs/index.test.ts | 7 +- apps/web/src/trpc/commands/task-runs/index.ts | 51 +- .../commands/tasks/__tests__/delete.test.ts | 93 +- apps/web/src/trpc/commands/tasks/delete.ts | 26 + apps/web/src/trpc/routers/_app.ts | 20 +- apps/web/src/types/preferences.ts | 2 - .../__tests__/slack-live-task-stream.test.ts | 18 +- .../src/callbacks/slack-live-task-stream.ts | 10 +- .../runtime-envelope-subscription.test.ts | 2 +- .../run-task/subscribe-harness-callbacks.ts | 4 +- packages/cloud-agents/package.json | 1 - .../src/server/__tests__/enqueue-task.test.ts | 42 +- .../fast-agent-conversation-repository.ts | 77 +- .../server/fast-agent/fast-agent-service.ts | 26 +- .../src/server/fast-agent/fast-agent-title.ts | 43 +- .../cloud-agents/src/server/task-run-queue.ts | 75 +- ...65_enable_fast_mode_for_existing_users.sql | 5 - .../0065_sessions_responding_until.sql | 1 + packages/db/drizzle/meta/0065_snapshot.json | 13902 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 4 +- .../db/src/lib/__tests__/sessions.test.ts | 40 +- packages/db/src/lib/sessions.ts | 91 +- packages/db/src/schema.ts | 7 +- .../src/__tests__/config.test.ts | 33 +- .../evaluateFlagFromMetadata.test.ts | 175 +- packages/feature-flags/src/config.ts | 21 +- .../src/server/deployment.test.ts | 39 +- packages/feature-flags/src/types.ts | 6 +- packages/slack/package.json | 1 - .../fast-agent-live-task-launcher.test.ts | 51 +- .../__tests__/settle-live-task-card.test.ts | 2 +- packages/slack/src/client.ts | 2 +- .../src/fast-agent-live-task-launcher.ts | 40 +- packages/slack/src/live-task-card-blocks.ts | 15 +- packages/slack/src/live-task-stream.ts | 1 - packages/slack/src/settle-live-task-card.ts | 16 +- packages/types/src/index.ts | 1 + packages/types/src/sessions.ts | 15 + pnpm-lock.yaml | 9 - 98 files changed, 15751 insertions(+), 2414 deletions(-) delete mode 100644 apps/api/src/handlers/fast-agent-entry.test.ts delete mode 100644 apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx create mode 100644 apps/web/src/components/sessions/SessionStatusBadge.tsx create mode 100644 apps/web/src/components/sessions/session-surfaces.ts delete mode 100644 apps/web/src/hooks/task-runs/useRouteHomeTask.ts create mode 100644 apps/web/src/hooks/useMarkSessionRead.ts create mode 100644 apps/web/src/lib/server/analytics/session-rows.test.ts delete mode 100644 packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql create mode 100644 packages/db/drizzle/0065_sessions_responding_until.sql create mode 100644 packages/db/drizzle/meta/0065_snapshot.json create mode 100644 packages/types/src/sessions.ts diff --git a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts index 598a722e2..fcdee832e 100644 --- a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts +++ b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts @@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({ createDirectMessage: vi.fn(), postMessage: vi.fn(), addReaction: vi.fn(), - hasFastDefault: vi.fn(), processFast: vi.fn(), })); @@ -41,10 +40,6 @@ vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUserId, })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - vi.mock('../../shared/channel-launch-gate.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../shared/channel-launch-gate.js') @@ -120,6 +115,24 @@ function messagePayload(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so launch-path tests +// use an attachment-only human message (no text for Fast mode to answer). +function attachmentOnlyPayload(overrides: Record = {}) { + return messagePayload({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + function gatewayEvent(payload: Record): DiscordGatewayEvent { return { eventId: String(payload.id), @@ -204,13 +217,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { mocks.createDirectMessage.mockResolvedValue({ id: 'dm-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-message-1' }); mocks.addReaction.mockResolvedValue(undefined); - mocks.hasFastDefault.mockResolvedValue(false); mocks.processFast.mockResolvedValue(undefined); }); - it('routes a linked user default to Fast mode before channel auto-start launch', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - + it('routes a linked-human text message to Fast mode before channel auto-start launch', async () => { await expect(runHandler({})).resolves.toBe(true); await flushBackgroundWork(); @@ -266,6 +276,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { runHandler({ payload: messagePayload({ content: '', + author: { id: 'alert-bot', username: 'alerts', bot: true }, message_snapshots: [ { message: { @@ -315,8 +326,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('launches a linked-human message with instructions as the prompt prefix', async () => { - await expect(runHandler({})).resolves.toBe(true); + it('launches a linked-human attachment message with instructions as the prompt prefix', async () => { + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -347,7 +360,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('forwards message_reference into startNewDiscordTask for reply launches', async () => { await expect( runHandler({ - payload: messagePayload({ + payload: attachmentOnlyPayload({ type: 19, message_reference: { message_id: 'parent-message-1', @@ -472,7 +485,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'skip', reason: 'not an incident' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.evaluateGate).toHaveBeenCalledWith( @@ -507,7 +522,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'error', reason: 'provider unavailable' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).not.toHaveBeenCalled(); @@ -521,7 +538,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('replies when task startup throws', async () => { mocks.startNewTask.mockRejectedValue(new Error('task queue unavailable')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.postMessage).toHaveBeenCalledWith({ @@ -590,7 +609,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('never lets a reaction failure abort the launch', async () => { mocks.addReaction.mockRejectedValue(new Error('rate limited')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).toHaveBeenCalledTimes(1); @@ -603,7 +624,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('releases the routing lock when the launch fails', async () => { mocks.startNewTask.mockRejectedValue(new Error('boom')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.redis.del).toHaveBeenCalledWith( diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 0b93a629e..11037cbf5 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -60,7 +60,6 @@ const mocks = vi.hoisted(() => ({ startGoal: vi.fn(), acquireFastTurnLock: vi.fn(), answerFast: vi.fn(), - hasFastDefault: vi.fn(), hasFastSession: vi.fn(), findFastReplySession: vi.fn(), isFastProviderMessage: vi.fn(), @@ -190,10 +189,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ .mockResolvedValue({ id: 'fast-session-1' }), })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - import { discord, discordGatewayEventProcessingTimeout } from '../index.js'; import { discordApiEventLeaseRenewal } from '../event-gate.js'; @@ -236,6 +231,24 @@ function message(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so task-orchestration +// tests use attachment-only messages (no text for Fast mode to answer). +function attachmentMessage(overrides: Record = {}) { + return message({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + async function postEvent(body: unknown, secret = 'gateway-secret') { return app.request('http://localhost/api/internal/discord/events/process', { method: 'POST', @@ -306,7 +319,6 @@ describe('Discord Gateway event handler', () => { vi.fn().mockResolvedValue(undefined), ); mocks.answerFast.mockResolvedValue('A quick answer'); - mocks.hasFastDefault.mockResolvedValue(false); mocks.hasFastSession.mockResolvedValue(false); mocks.findFastReplySession.mockResolvedValue(null); mocks.isFastProviderMessage.mockResolvedValue(false); @@ -382,10 +394,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'use API instead', channel: { id: 'thread-1', type: 11, @@ -404,7 +415,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.handleRoutingReply).toHaveBeenCalledWith( expect.objectContaining({ pendingRouteId: 'pending-route-1', - queuedMessage: expect.objectContaining({ text: 'use API instead' }), + queuedMessage: expect.objectContaining({ text: 'Image: context.png' }), }), ); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -726,8 +737,8 @@ describe('Discord Gateway event handler', () => { }, ); - it('launches a linked DM request through the Discord task orchestrator', async () => { - const response = await postEvent(envelope(message())); + it('launches a linked DM attachment request through the Discord task orchestrator', async () => { + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.completeEvent).toHaveBeenCalledWith({ @@ -747,7 +758,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, queuedMessage: expect.objectContaining({ provider: 'discord', - text: 'Fix the flaky tests', + text: 'Image: context.png', userId: 'roomote-user-1', }), metadata: { @@ -762,8 +773,6 @@ describe('Discord Gateway event handler', () => { }); it('routes an ordinary linked DM message through Fast mode when the user default is enabled', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - const response = await postEvent(envelope(message())); expect(response.status).toBe(200); @@ -798,7 +807,6 @@ describe('Discord Gateway event handler', () => { }); it('starts a new guild-channel Fast conversation in an anchored thread', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.getChannel.mockResolvedValue({ id: 'channel-1', name: 'general', @@ -851,7 +859,6 @@ describe('Discord Gateway event handler', () => { }); it('passes the model-authored Fast kickoff through the Discord enqueue gate', async () => { - mocks.hasFastDefault.mockResolvedValue(true); const postKickoff = vi.fn().mockResolvedValue(undefined); mocks.startNewTask.mockImplementation( async (input: { @@ -910,7 +917,6 @@ describe('Discord Gateway event handler', () => { }); it('serializes complete Fast turns before the next Discord message enters the agent', async () => { - mocks.hasFastDefault.mockResolvedValue(true); let grantSecondLock!: (release: () => Promise) => void; const secondLock = new Promise<() => Promise>((resolve) => { grantSecondLock = resolve; @@ -982,7 +988,6 @@ describe('Discord Gateway event handler', () => { }); it('gives defaulted Discord Fast mode the active task for thread continuation', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.findActiveRun.mockResolvedValue({ id: 23, taskId: 'task-23', @@ -1006,11 +1011,11 @@ describe('Discord Gateway event handler', () => { }); const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> can you check if this issue already exists?', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'message-parent', @@ -1047,11 +1052,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: 'Could you expand on the migration note?', message_reference: { message_id: 'announcer-root', channel_id: 'channel-1', @@ -1099,11 +1103,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1117,7 +1121,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.queueMessage).toHaveBeenCalledWith( 'discord', 11, - expect.objectContaining({ text: 'follow up on the first report' }), + expect.objectContaining({ text: 'Image: context.png' }), ); expect(mocks.findActiveRun).not.toHaveBeenCalled(); }); @@ -1148,11 +1152,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1173,7 +1177,7 @@ describe('Discord Gateway event handler', () => { it('still launches when the initial eyes reaction fails', async () => { mocks.addReaction.mockRejectedValueOnce(new Error('rate limited')); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -1230,7 +1234,7 @@ describe('Discord Gateway event handler', () => { ); }); - it('queues an ordinary message in an active Discord task thread with full thread context', async () => { + it('queues an attachment-only message in an active Discord task thread with full thread context', async () => { mocks.getChannel.mockResolvedValue({ id: 'thread-1', guildId: 'guild-1', @@ -1246,10 +1250,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Also fix the type error', }), ), ); @@ -1260,7 +1263,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', }), }), ); @@ -1269,7 +1272,7 @@ describe('Discord Gateway event handler', () => { taskId: 'task-23', provider: 'discord', message: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -1278,7 +1281,7 @@ describe('Discord Gateway event handler', () => { 'discord', 23, expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), turnPolicy: { reactionsAllowed: true }, }), @@ -1309,10 +1312,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'what about that earlier note?', message_reference: { message_id: 'earlier-1', channel_id: 'thread-1', @@ -1328,7 +1330,7 @@ describe('Discord Gateway event handler', () => { replyToMessageId: 'earlier-1', replyToChannelId: 'thread-1', queuedMessage: expect.objectContaining({ - text: 'what about that earlier note?', + text: 'Image: context.png', }), }), ); @@ -1361,10 +1363,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'yes fix those', }), ), ); @@ -1384,7 +1385,7 @@ describe('Discord Gateway event handler', () => { mocks.findSourceRun.mockResolvedValue({ id: 23, taskId: 'task-23' }); mocks.getTaskUrl.mockReturnValue('https://roomote.example/task/task-23'); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.queueMessage).not.toHaveBeenCalled(); @@ -2334,10 +2335,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'discussion-thread', guild_id: 'guild-1', - content: '<@bot-1> investigate the flaky build', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ), @@ -2376,10 +2377,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2390,7 +2390,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', }), }), ); @@ -2413,7 +2413,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, }, queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -2440,10 +2440,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2507,10 +2506,10 @@ describe('Discord Gateway event handler', () => { }, ); const originalEvent = envelope( - message({ + attachmentMessage({ channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> fix this', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ); @@ -2560,7 +2559,7 @@ describe('Discord Gateway event handler', () => { requesterDiscordUserId: 'discord-user-1', launchOwnerUserId: 'roomote-user-1', queuedMessage: expect.objectContaining({ - text: 'fix this', + text: 'Image: context.png', ts: 'message-1', userId: 'roomote-user-1', }), @@ -2577,7 +2576,7 @@ describe('Discord Gateway event handler', () => { }); it('restores the pending request and link code when continuation fails', async () => { - const originalEvent = envelope(message()); + const originalEvent = envelope(attachmentMessage()); mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); mocks.findMappedUserId.mockResolvedValue('roomote-user-1'); mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 2fe6de4a6..6523501ed 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -24,7 +24,6 @@ import { } from '@roomote/types'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { checkAutoStartChannelCache } from '../shared/auto-start-cache.js'; import { CHANNEL_AUTO_START_FAILURE_MESSAGE, @@ -279,10 +278,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { getDiscordMessageContent(message), botUserId, ); - if ( - defaultFastQuestion && - (await hasCommunicationsFastModeDefault(mappedUserId)) - ) { + if (defaultFastQuestion) { void processDiscordFastAgentMessage({ event, question: defaultFastQuestion, diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index e8a7b8cd8..ac4ce6cae 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -46,7 +46,6 @@ import { } from '@roomote/sdk/server'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; import { @@ -745,10 +744,11 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); + // Fast mode is unconditional for ordinary linked-human messages. Reaction + // entries carry a configured task prompt, so they keep launching tasks + // (mirroring Slack's call-roomote-via-emoji flow). const defaultFastMessage = - message != null && - command == null && - (await hasCommunicationsFastModeDefault(senderUserId)) + message != null && command == null && reactionTarget == null ? message : null; diff --git a/apps/api/src/handlers/fast-agent-entry.test.ts b/apps/api/src/handlers/fast-agent-entry.test.ts deleted file mode 100644 index bc96b3ff4..000000000 --- a/apps/api/src/handlers/fast-agent-entry.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -const mocks = vi.hoisted(() => ({ - findUser: vi.fn(), -})); - -vi.mock('@roomote/db/server', () => ({ - db: { query: { users: { findFirst: mocks.findUser } } }, - eq: vi.fn(), - users: { id: 'users.id' }, -})); - -import { hasCommunicationsFastModeDefault } from './fast-agent-entry'; - -describe('hasCommunicationsFastModeDefault', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns the stored preference', async () => { - mocks.findUser.mockResolvedValue({ - metadata: { communications_fast_mode_default: true }, - }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - true, - ); - }); - - it('defaults to enabled when no preference is stored', async () => { - mocks.findUser.mockResolvedValue({ metadata: {} }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - true, - ); - }); - - it('honors an explicit opt-out', async () => { - mocks.findUser.mockResolvedValue({ - metadata: { communications_fast_mode_default: false }, - }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - false, - ); - }); -}); diff --git a/apps/api/src/handlers/fast-agent-entry.ts b/apps/api/src/handlers/fast-agent-entry.ts index 02e4cb83b..bb7ad4585 100644 --- a/apps/api/src/handlers/fast-agent-entry.ts +++ b/apps/api/src/handlers/fast-agent-entry.ts @@ -1,5 +1,3 @@ -import { db, eq, users } from '@roomote/db/server'; - type FastAgentEntryMode = 'explicit' | 'default'; export function resolveFastAgentEntryMode(params: { @@ -12,21 +10,3 @@ export function resolveFastAgentEntryMode(params: { return params.userDefaultEnabled ? 'default' : null; } - -export async function hasCommunicationsFastModeDefault( - userId: string, -): Promise { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { metadata: true }, - }); - const metadata = user?.metadata; - - return !( - typeof metadata === 'object' && - metadata !== null && - !Array.isArray(metadata) && - (metadata as Record).communications_fast_mode_default === - false - ); -} diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index 438ae3530..4f7cacf02 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -150,7 +150,6 @@ describe('channel auto-start unlinked author', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); const { handleMessageOrAppMentionEvent } = diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index f459d5d25..8b3548ec4 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -1248,11 +1248,9 @@ async function maybeHandleChannelAutoStart(params: { explicitInvocation: isBareFastCommandInvocation( channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, ), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation( - channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, - ), + userDefaultEnabled: !isRemovedEvalCommandInvocation( + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, + ), }) : null; @@ -1733,9 +1731,7 @@ async function handleSlackEntryEvent(params: { const authoredEventText = event.authoredText ?? event.text; const fastAgentEntryMode = resolveFastAgentEntryMode({ explicitInvocation: isFastCommandInvocation(authoredEventText), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation(authoredEventText), + userDefaultEnabled: !isRemovedEvalCommandInvocation(authoredEventText), }); if (fastAgentEntryMode) { diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index 7c5bcfbb4..b66f5fb43 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -24,7 +24,6 @@ vi.mock('@roomote/db/server', () => ({ users: { id: 'users.id', deletedAt: 'users.deletedAt', - metadata: 'users.metadata', }, })); @@ -52,7 +51,6 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); @@ -68,39 +66,11 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, - communicationsFastModeDefault: true, }, hasInactiveMapping: false, }); }); - it('enables Fast mode by default for active mappings without a preference', async () => { - const createdAt = new Date('2024-01-01T00:00:00.000Z'); - const updatedAt = new Date('2024-01-02T00:00:00.000Z'); - limitMock.mockResolvedValueOnce([ - { - id: 'mapping-1', - slackUserId: 'U123', - slackTeamId: 'T123', - userId: 'user-1', - createdAt, - updatedAt, - matchedUserId: 'user-1', - userDeletedAt: null, - userMetadata: {}, - }, - ]); - - const { lookupSlackUserMapping } = await import('./user-mapping.js'); - - await expect( - lookupSlackUserMapping({ slackUserId: 'U123', teamId: 'T123' }), - ).resolves.toMatchObject({ - activeMapping: { communicationsFastModeDefault: true }, - hasInactiveMapping: false, - }); - }); - it('flags stale mappings whose linked user was removed', async () => { limitMock.mockResolvedValueOnce([ { @@ -112,7 +82,6 @@ describe('lookupSlackUserMapping', () => { updatedAt: new Date('2024-01-02T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: new Date('2024-02-01T00:00:00.000Z'), - userMetadata: {}, }, ]); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 7f32c8900..4aa738842 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,9 +8,7 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: - | (SlackUserMapping & { communicationsFastModeDefault: boolean }) - | null; + activeMapping: SlackUserMapping | null; hasInactiveMapping: boolean; }; @@ -28,7 +26,6 @@ export async function lookupSlackUserMapping(params: { updatedAt: slackUserMappings.updatedAt, matchedUserId: users.id, userDeletedAt: users.deletedAt, - userMetadata: users.metadata, }) .from(slackUserMappings) .leftJoin(users, eq(users.id, slackUserMappings.userId)) @@ -62,13 +59,6 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, - communicationsFastModeDefault: !( - typeof row.userMetadata === 'object' && - row.userMetadata !== null && - !Array.isArray(row.userMetadata) && - (row.userMetadata as Record) - .communications_fast_mode_default === false - ), }, hasInactiveMapping: false, }; diff --git a/apps/bullmq/package.json b/apps/bullmq/package.json index abbc24cdf..3858ea93b 100644 --- a/apps/bullmq/package.json +++ b/apps/bullmq/package.json @@ -27,7 +27,6 @@ "@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 index ccdc8aab2..892be01d4 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -1,6 +1,5 @@ import { db, - deploymentSettings, eq, fastAgentConversations, sessionTasks, @@ -11,23 +10,6 @@ import { 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 @@ -54,4 +36,31 @@ describe('sessionsReconcileJob', () => { db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), ).resolves.toHaveLength(1); }); + + it('adopts orphan Fast conversations during steady-state reconciliation', async () => { + // Complete (or advance) the one-time backfill first so the next run takes + // the steady-state reconciliation path. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + }); }); diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts index 470217c30..ce8591dde 100644 --- a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -17,11 +17,6 @@ import { 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; @@ -175,6 +170,26 @@ async function backfillParticipants(): Promise { } async function reconcileRecentSessions(): Promise { + // Fast conversations without a session row (e.g. created before this + // release finished its backfill) are adopted here so the unified list + // converges without another full backfill. + const orphanConversations = await db + .select({ id: fastAgentConversations.id }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where(isNull(sessions.id)) + .orderBy(desc(fastAgentConversations.updatedAt)) + .limit(BATCH_SIZE); + + for (const conversation of orphanConversations) { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation.id), + ); + } + const orphanTasks = await db .select({ id: tasks.id }) .from(tasks) @@ -206,15 +221,13 @@ async function reconcileRecentSessions(): Promise { } console.info(`${LOG_PREFIX} reconciliation`, { + orphanFastConversations: orphanConversations.length, 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), }); @@ -233,11 +246,7 @@ export async function sessionsReconcileJob(): Promise { const complete = await backfillFastConversations(cursor); if (!complete) return; } - if ( - phase === 'fast_conversations' || - phase === 'fast_tasks' || - phase === 'tasks' - ) { + if (phase === 'fast_conversations' || phase === 'tasks') { const complete = await backfillTasks(phase === 'tasks' ? cursor : null); if (!complete) return; } diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index ad3bd3bfd..8af762abb 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -64,12 +64,6 @@ Personal Settings also include app preferences such as: - **Mind Reader Mode** to expand LLM thoughts by default in task conversations; you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view -- **Fast response mode** is enabled by default for new homepage prompts and - messages sent from your linked Slack and Discord accounts. Turn it off here - if you prefer task execution by default. An explicit homepage workspace - choice takes precedence. The chat preference does not apply to GitHub, Teams, - or Telegram. You can still use `!fast` explicitly in Slack whether the - preference is on or off. Most teammates only need profile, linked accounts, and theme settings. diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 3be20c006..a3857c3af 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -120,9 +120,9 @@ under **Settings > Automations**, the same way you would pick a Slack channel. current one - use `/goal objective:` to keep working toward an objective across multiple turns in an active task thread or DM; this does not create a new task -- **Fast response mode** is enabled by default for ordinary Discord DMs, - mentions, and eligible thread replies from your linked account; turn it off - under **Settings > Personal** to start tasks by default instead +- ordinary Discord DMs, mentions, and eligible thread replies from your linked + account are always answered in Fast mode, which can delegate repository work + into tasks - when Roomote asks where to run a task, use a button or reply naturally in the same thread or DM; `yes`, `never mind`, and `use API instead` confirm, cancel, or revise the pending route diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index a71da3221..7e5aa0671 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -181,10 +181,9 @@ Mention the app and use `!fast ` to ask the fast orchestrator a question or delegate work into a task. For example: `@Roomote !fast summarize this thread` or `@Roomote !fast fix the failing CI job`. -**Fast response mode** is enabled by default. Turn it off under -**Settings > Personal** if you want ordinary messages from your linked Slack -and Discord accounts to start tasks instead of going through the fast -orchestrator. You can still use `!fast` for an explicit Fast request. +**Fast response mode** is always on: ordinary messages from your linked Slack +and Discord accounts go through the fast orchestrator, which can delegate work +into tasks. `!fast` remains available for an explicit Fast request. Fast can read a bounded history from the current Slack channel, use MCP servers and user-scoped integrations that you are allowed to access, and delegate diff --git a/apps/docs/tasks.mdx b/apps/docs/tasks.mdx index 760b77cda..6ae7279d5 100644 --- a/apps/docs/tasks.mdx +++ b/apps/docs/tasks.mdx @@ -49,9 +49,8 @@ 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. - +- a header breadcrumb linking 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)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 0b5a17fbd..36e3a55f8 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -7,7 +7,6 @@ import { } from '@testing-library/react'; import { ALL_REPOSITORIES, FAST_EXECUTION } from '@roomote/types'; -import type { RoutingDecision } from '@roomote/cloud-agents/server'; import type { PromptInputMessage } from '@/components/ai-elements'; import { AUTO_WORKSPACE_VALUE } from '@/components/tasks/constants'; @@ -19,9 +18,6 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ { id: 'env-2', name: 'Secondary Env' }, ]; let currentEnvironmentsPending = false; -let currentCommunicationsFastModeDefault = false; -let currentPersonalPreferencesLoading = false; -let currentSessionsUiEnabled = false; const { mockPush, @@ -32,8 +28,6 @@ const { mockUseCreateStandardTaskRun, mockCreateStandardTaskRun, mockUseLaunchTaskModels, - mockUseRouteHomeTask, - mockRouteHomeTask, mockPreparePromptAttachments, mockStartFastSession, } = vi.hoisted(() => ({ @@ -45,8 +39,6 @@ const { mockUseCreateStandardTaskRun: vi.fn(), mockCreateStandardTaskRun: vi.fn(), mockUseLaunchTaskModels: vi.fn(), - mockUseRouteHomeTask: vi.fn(), - mockRouteHomeTask: vi.fn(), mockPreparePromptAttachments: vi.fn(), mockStartFastSession: vi.fn(), })); @@ -75,7 +67,6 @@ vi.mock('@/hooks/useUser', () => ({ name: 'Test User', primaryEmail: 'test@example.com', cloudEnabled: currentCloudEnabled, - featureFlags: { sessions_ui: currentSessionsUiEnabled }, resource: { username: 'tester', fullName: 'Test User', @@ -97,23 +88,8 @@ vi.mock('@/hooks/environments', () => ({ }), })); -vi.mock('@/hooks/usePersonalPreferences', () => ({ - usePersonalPreferences: () => ({ - preferences: { - colorTheme: 'system', - mindReaderMode: false, - narrationMode: false, - communicationsFastModeDefault: currentCommunicationsFastModeDefault, - }, - isLoading: currentPersonalPreferencesLoading, - isUpdating: false, - setPreferences: vi.fn(), - }), -})); - vi.mock('@/hooks/task-runs', () => ({ useCreateStandardTaskRun: mockUseCreateStandardTaskRun, - useRouteHomeTask: mockUseRouteHomeTask, useStartFastSession: () => ({ isPending: false, mutateAsync: mockStartFastSession, @@ -135,17 +111,6 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: mockUseLaunchTaskModels, })); -vi.mock('@/components/system', async () => { - const actual = await vi.importActual( - '@/components/system', - ); - - return { - ...actual, - Loader2: (props: React.ComponentProps<'svg'>) => , - }; -}); - vi.mock('@/lib', () => ({ processImageFiles: mockProcessImageFiles, })); @@ -181,13 +146,11 @@ vi.mock('@/components/tasks', async () => { ...actual, SelectWorkspace: ({ allowAuto, - allowFast, autoSelectDefaultWorkspace, onInvalidWorkspaceReset, allowBranchSelection, }: { allowAuto?: boolean; - allowFast?: boolean; autoSelectDefaultWorkspace?: boolean; onInvalidWorkspaceReset?: () => void; allowBranchSelection?: boolean; @@ -236,18 +199,16 @@ vi.mock('@/components/tasks', async () => { > Use auto workspace - {allowFast && ( - - )} +

- - {status.replace('_', ' ')} - + {session.executionCount} executions - {session.sourceSurface} + {getSessionSurfaceLabel(session.sourceSurface)} {primaryTask?.repositoryName ? ( {primaryTask.repositoryName} ) : null} - ${(session.inferenceCostMicroUsd / 1_000_000).toFixed(4)} + ${formatInferenceCost(session.inferenceCostMicroUsd)}
diff --git a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx index 60452c892..a6649ab96 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx @@ -3,7 +3,10 @@ import { useCallback } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { getSessionStatusLabel, SESSION_STATUSES } from '@roomote/types'; + import type { TimePeriodFilter } from '@/types'; +import { SESSION_SURFACES } from '@/components/sessions/session-surfaces'; import { TaskFilters } from '@/components/tasks'; import { Button, @@ -18,7 +21,6 @@ import { export function SessionsFilters({ userId, timePeriod, - unified = false, scope = 'all', status = 'all', view = 'list', @@ -31,7 +33,6 @@ export function SessionsFilters({ }: { userId: string | null; timePeriod: TimePeriodFilter; - unified?: boolean; scope?: string; status?: string; view?: string; @@ -60,118 +61,113 @@ export function SessionsFilters({ return (
- {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} + + + +
{ + 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'); + }); + }} + > + + + +
+
diff --git a/apps/web/src/app/(authenticated)/sessions/page.tsx b/apps/web/src/app/(authenticated)/sessions/page.tsx index e7abfa15b..d6f989a30 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.tsx @@ -1,13 +1,13 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; +import { SESSION_STATUSES, type SessionStatus } from '@roomote/types'; + 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'; @@ -37,162 +37,106 @@ export default async function SessionsPage({ 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( + const status = (SESSION_STATUSES as readonly string[]).includes( params.status ?? '', ) - ? (params.status as 'active' | 'needs_input' | 'blocked' | 'ready') + ? (params.status as SessionStatus) : 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, { + const result = await getSessions(authorizedUser, { before, - filterUserId: user ?? null, - timePeriod, + 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(); - if (nextCursor) olderParams.set('before', nextCursor); - if (user) olderParams.set('user', user); - if (timePeriod !== 'all') olderParams.set('period', String(timePeriod)); + Object.entries(params).forEach(([key, value]) => { + if (value && key !== 'before') olderParams.set(key, value); + }); + if (result.nextCursor) olderParams.set('before', result.nextCursor); + const columns = SESSION_STATUSES; return (
-
- -
+
- -
-
- {sessions.length === 0 ? ( - - - No sessions yet. - - - ) : ( -
- {sessions.map((session) => ( - - ))} - {nextCursor ? ( -
- - Show older sessions - +
+ {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) => ( + + ))}
- ) : null} -
- )} -
-
+ + ))} +
+ ) : ( +
+ {result.sessions.map((session) => ( + + ))} +
+ )} + {result.nextCursor ? ( +
+ + Show older sessions + +
+ ) : null} +
); } diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx index 72895ac17..cbc48735e 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -1,16 +1,15 @@ 'use client'; import { useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { Tasks } from './Tasks'; -import { useAuthorizedUser } from '@/hooks/useUser'; +// Sessions is the primary workspace; this page is intentionally unlinked from +// the primary nav but stays fully functional for direct URLs and deep links. export default function Page() { const searchParams = useSearchParams(); - const router = useRouter(); - const { featureFlags } = useAuthorizedUser(); const error = searchParams.get('error'); useEffect(() => { @@ -19,26 +18,5 @@ 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)/sessions/[sessionId]/SessionReadTracker.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx index 042215a64..58717a7c4 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx @@ -2,37 +2,20 @@ import { useEffect } from 'react'; +import { useMarkSessionRead } from '@/hooks/useMarkSessionRead'; 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(); + useMarkSessionRead(sessionId); + 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]); + }, [capture, recordVisit, sessionId]); return null; } diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx index dc1615898..f30381348 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { formatInferenceCost } from '@/lib'; import { Badge, Button, @@ -105,7 +106,7 @@ export function SessionTaskCards({

{task.latestOutput}

) : null}

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

{task.canAccessDetails === false ? (

Execution details require task access.

diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 187335929..645554bbf 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,12 +1,23 @@ 'use client'; import Link from 'next/link'; -import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useRef, + 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 { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; +import { + getSessionSurfaceBrandIcon, + getSessionSurfaceLabel, +} from '@/components/sessions/session-surfaces'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; import { useTRPC } from '@/trpc/client'; import { FramedSurface, WorkspaceSurface } from '@/components/layout'; @@ -15,7 +26,6 @@ import { ArrowLeftFromLine, Avatar, BasicTooltip, - Badge, BrandIcon, Brain, Button, @@ -69,51 +79,6 @@ export type SessionInfo = { taskCards?: Array>; }; -const SURFACE_LABELS: Record = { - slack: 'Slack', - linear: 'Linear', - github: 'GitHub', - gitlab: 'GitLab', - gitea: 'Gitea', - bitbucket: 'Bitbucket', - ado: 'Azure DevOps', - discord: 'Discord', - teams: 'Teams', - telegram: 'Telegram', - automation: 'Automation', - web: 'Web', -}; - -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, @@ -277,8 +242,8 @@ function SessionInfoPanel({ .filter(Boolean) .join(' • '); const inferenceCostLabel = formatInferenceCost(session.inferenceCostMicroUsd); - const surfaceLabel = SURFACE_LABELS[session.surface] ?? session.surface; - const surfaceBrandIcon = SURFACE_BRAND_ICONS[session.surface]; + const surfaceLabel = getSessionSurfaceLabel(session.surface); + const surfaceBrandIcon = getSessionSurfaceBrandIcon(session.surface); return ( {session.status ? ( - - {session.status.replace('_', ' ')} - + ) : null} @@ -355,6 +318,11 @@ function SessionInfoPanel({ ); } +type WorkspacePanel = + | { kind: 'info' } + | { kind: 'tasks' } + | { kind: 'nested'; taskId: string }; + export function SessionWorkspace({ session, children, @@ -362,9 +330,10 @@ export function SessionWorkspace({ session: SessionInfo; children: ReactNode; }) { - const [isInfoOpen, setIsInfoOpen] = useState(false); - const [isTasksOpen, setIsTasksOpen] = useState(false); - const [nestedTaskId, setNestedTaskId] = useState(null); + // Exactly one side panel can be active: the discriminated union makes an + // impossible combination unrepresentable. The URL's ?task= selection is the + // fourth panel and always wins over `panel` when both are set. + const [panel, setPanel] = useState(null); const trpc = useTRPC(); const router = useRouter(); const searchParams = useSearchParams(); @@ -374,7 +343,13 @@ export function SessionWorkspace({ { sessionId: session.id }, { enabled: !isFastTaskSource, - refetchInterval: 2_000, + // Settled sessions poll slowly; only visibly-running work needs the + // fast cadence. TanStack pauses both while the tab is unfocused. + refetchInterval: (query) => + query.state.data?.status === 'active' || + query.state.data?.status === 'needs_input' + ? 2_000 + : 30_000, }, ), ); @@ -395,8 +370,7 @@ export function SessionWorkspace({ const selectedTask = sessionTasks.find( (task) => task.taskId === selectedTaskId, ); - const panelOpen = - isInfoOpen || isTasksOpen || Boolean(selectedTask) || Boolean(nestedTaskId); + const panelOpen = panel !== null || Boolean(selectedTask); const selectTask = useCallback( (taskId: string | null) => { @@ -409,46 +383,52 @@ export function SessionWorkspace({ [router, searchParams, session.id], ); + // Default a single-task session to its task panel once, on mount only — an + // explicit close or panel choice must never be fought by a re-select. + const didAutoSelect = useRef(false); useEffect(() => { - if (!isTasksOpen && !selectedTaskId && session.tasks.length === 1) { + if (didAutoSelect.current) return; + didAutoSelect.current = true; + if (!selectedTaskId && session.tasks.length === 1) { selectTask(session.tasks[0]!.taskId); } - }, [isTasksOpen, selectTask, selectedTaskId, session.tasks]); + }, [selectTask, selectedTaskId, session.tasks]); const openTaskPanel = useCallback( (taskId: string) => { - setIsInfoOpen(false); - setIsTasksOpen(false); - setNestedTaskId(taskId); + setPanel({ kind: 'nested', taskId }); selectTask(null); }, [selectTask], ); const closePanel = () => { - setIsInfoOpen(false); - setIsTasksOpen(false); - setNestedTaskId(null); + setPanel(null); selectTask(null); }; - const panelContent = nestedTaskId ? ( - - ) : selectedTask ? ( - - ) : isTasksOpen ? ( - - ) : ( - - ); + const togglePanel = (kind: 'info' | 'tasks') => { + setPanel((previous) => (previous?.kind === kind ? null : { kind })); + selectTask(null); + }; + const panelContent = + panel?.kind === 'nested' ? ( + + ) : selectedTask ? ( + + ) : panel?.kind === 'tasks' ? ( + + ) : ( + + ); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); useResponsiveSandboxSidebar(session.id); @@ -463,28 +443,18 @@ export function SessionWorkspace({ side="right" label="Session info" tooltip="Session info" - active={isInfoOpen && !selectedTask && !nestedTaskId} + active={panel?.kind === 'info' && !selectedTask} icon={Info} - onClick={() => { - setIsTasksOpen(false); - setNestedTaskId(null); - selectTask(null); - setIsInfoOpen((previous) => !previous); - }} + onClick={() => togglePanel('info')} /> { - setNestedTaskId(null); - setIsInfoOpen(false); - selectTask(null); - setIsTasksOpen((previous) => !previous); - }} + onClick={() => togglePanel('tasks')} /> {!isSidebarVisible && !panelOpen ? ( 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 330b357cb..e7c3aa50e 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -60,10 +60,13 @@ vi.mock('./SessionWorkspace', () => ({ vi.mock('./SessionReadTracker', () => ({ SessionReadTracker: () => null, })); +vi.mock('./SessionTaskCards', () => ({ + SessionTaskCards: () =>
, +})); import SessionDetailPage from './page'; -describe('Fast session detail page', () => { +describe('Session detail page', () => { beforeEach(() => { vi.clearAllMocks(); getSessionByIdCommandMock.mockResolvedValue(null); @@ -193,12 +196,11 @@ describe('Fast session detail page', () => { ); }); - it('keeps the legacy Fast detail path when Sessions UI is disabled', async () => { + it('resolves the unified session first and renders its Fast transcript', async () => { authorizeMock.mockResolvedValue({ success: true, userId: 'user-1', isAdmin: false, - featureFlags: { sessions_ui: false }, }); getSessionByIdCommandMock.mockResolvedValue({ id: 'unified-session-1', @@ -230,6 +232,98 @@ describe('Fast session detail page', () => { messages: [], hasOlderMessages: false, }); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ sessionId: 'unified-session-1' }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'unified-session-1', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'fast-session-3', + ); + expect(getFastSessionTasksMock).not.toHaveBeenCalled(); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: 'unified-session-1', + status: 'active', + tasks: [expect.objectContaining({ taskId: 'task-1' })], + }), + }), + undefined, + ); + expect(transcriptMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-3', + canReply: true, + initialTitle: 'Session title', + fallbackTitle: 'Session title', + }), + undefined, + ); + }); + + it('renders a task-only workspace for unified sessions without a Fast conversation', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: 'unified-session-2', + title: 'Task-only session', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'web', + fastConversationId: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'completed', + tasks: [ + { + taskId: 'task-2', + title: 'Delegated task', + }, + ], + }); + + const html = renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ sessionId: 'unified-session-2' }), + }), + ); + + expect(getFastSessionByIdMock).not.toHaveBeenCalled(); + expect(transcriptMock).not.toHaveBeenCalled(); + expect(html).toContain('Task-only session'); + }); + + it('falls back to the Fast conversation lookup when no session row exists', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getFastSessionByIdMock.mockResolvedValue({ + id: 'fast-session-3', + userId: 'user-1', + 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, + }); getFastSessionTasksMock.mockResolvedValue([ { taskId: 'task-1', title: 'Delegated task' }, ]); @@ -240,7 +334,10 @@ describe('Fast session detail page', () => { }), ); - expect(getSessionByIdCommandMock).not.toHaveBeenCalled(); + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'fast-session-3', + ); expect(getFastSessionByIdMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), 'fast-session-3', diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index be0d4b12d..4dc2b52b7 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -13,8 +13,8 @@ import { getFastSessionTasks, } from '@/lib/server/fast-sessions'; import { getSessionByIdCommand } from '@/trpc/commands/sessions'; -import { Badge } from '@/components/system'; import { WorkspaceHeader } from '@/components/layout'; +import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; import { FastSessionTranscript } from './FastSessionTranscript'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; @@ -34,10 +34,10 @@ export default async function SessionDetailPage({ notFound(); } - const sessionsUiEnabled = authorizedUser.featureFlags?.sessions_ui === true; - const unifiedSession = sessionsUiEnabled - ? await getSessionByIdCommand(authorizedUser, sessionId) - : null; + // Old links may carry a fast-conversation id whose session row hasn't been + // backfilled yet; getSessionByIdCommand falls back by fastConversationId, + // and the fast lookup below covers a conversation with no session row. + const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); const session = unifiedSession?.fastConversationId ? await getFastSessionById( authorizedUser, @@ -70,14 +70,6 @@ export default async function SessionDetailPage({ status: unifiedSession.status, tasks: unifiedSession.tasks, }; - const statusVariant = - unifiedSession.status === 'active' - ? 'success' - : unifiedSession.status === 'needs_input' - ? 'warning' - : unifiedSession.status === 'blocked' - ? 'destructive' - : 'secondary'; const taskCards = ( - {unifiedSession.status.replace('_', ' ')} - + } timelineExtras={taskCards} /> @@ -117,9 +107,7 @@ export default async function SessionDetailPage({

{unifiedSession.title}

- - {unifiedSession.status.replace('_', ' ')} - +
{taskCards}
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 d1abf0773..76c2ee383 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 @@ -6,13 +6,11 @@ const { useTRPCMock, updateTitleMutationMock, parentSessionQueryMock, - featureFlagState, } = vi.hoisted(() => ({ useSandboxLayoutMock: vi.fn(), useTRPCMock: vi.fn(), updateTitleMutationMock: vi.fn(async () => undefined), parentSessionQueryMock: vi.fn(), - featureFlagState: { sessionsUiEnabled: false }, })); vi.mock('../../use-sandbox-layout', () => ({ @@ -23,12 +21,6 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); -vi.mock('@/hooks/useUser', () => ({ - useAuthorizedUser: () => ({ - featureFlags: { sessions_ui: featureFlagState.sessionsUiEnabled }, - }), -})); - vi.mock('./TaskSessionReadTracker', () => ({ TaskSessionReadTracker: () => null, })); @@ -95,7 +87,6 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); - featureFlagState.sessionsUiEnabled = false; parentSessionQueryMock.mockResolvedValue({ sessionId: 'session-1', title: 'Parent Session', @@ -176,17 +167,7 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); - it('does not query or render Session links while Sessions UI is disabled', () => { - renderHeader(); - - expect(parentSessionQueryMock).not.toHaveBeenCalled(); - expect(screen.queryByRole('link', { name: 'Parent Session' })).toBeNull(); - expect(screen.queryByRole('link', { name: /Go to session/ })).toBeNull(); - }); - - it('renders the parent session link while Sessions UI is enabled', async () => { - featureFlagState.sessionsUiEnabled = true; - + it('always queries the parent session and renders its links', async () => { renderHeader(); expect( @@ -196,10 +177,10 @@ describe('Header', () => { 'href', '/sessions/session-1?task=task-123', ); + expect(parentSessionQueryMock).toHaveBeenCalled(); }); it('links to the Fast session when the task has no unified session', async () => { - featureFlagState.sessionsUiEnabled = true; parentSessionQueryMock.mockResolvedValue(null); renderHeader({ diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 5c68b7c53..d9d40eedb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -26,7 +26,6 @@ import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; import { useTRPC } from '@/trpc/client'; -import { useAuthorizedUser } from '@/hooks/useUser'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; @@ -39,35 +38,24 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); - const { featureFlags } = useAuthorizedUser(); - const sessionsUiEnabled = featureFlags?.sessions_ui === true; const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); - const parentSessionOptions = trpc.sessions?.forTask?.queryOptions( - { taskId }, - { enabled: sessionsUiEnabled }, - ) ?? { - queryKey: ['sessions', 'for-task', 'disabled', taskId], - queryFn: async () => null, - enabled: false, - }; - const { data: queriedParentSession } = useQuery(parentSessionOptions); - const parentSession = sessionsUiEnabled ? queriedParentSession : null; + const { data: parentSession } = useQuery( + trpc.sessions.forTask.queryOptions({ taskId }), + ); const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; const pullRequests = taskRun?.pullRequests ?? []; - const sessionHref = sessionsUiEnabled - ? parentSession - ? `/sessions/${parentSession.sessionId}?task=${taskId}` - : taskRun?.payload?.fastAgentSessionId - ? `/sessions/${taskRun.payload.fastAgentSessionId}` - : null - : null; + const sessionHref = parentSession + ? `/sessions/${parentSession.sessionId}?task=${taskId}` + : taskRun?.payload?.fastAgentSessionId + ? `/sessions/${taskRun.payload.fastAgentSessionId}` + : null; const badges = [ (environmentId || repo) && ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx index cff3edc13..9379672c0 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx @@ -1,28 +1,9 @@ 'use client'; -import { useEffect } from 'react'; - -import { useTRPCClient } from '@/trpc/client'; +import { useMarkSessionRead } from '@/hooks/useMarkSessionRead'; export function TaskSessionReadTracker({ sessionId }: { sessionId: string }) { - const trpc = useTRPCClient(); - - useEffect(() => { - 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); - return () => window.removeEventListener('focus', markRead); - }, [sessionId, trpc]); + useMarkSessionRead(sessionId); return null; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx index f6d845cae..381ac1c62 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx @@ -51,9 +51,15 @@ describe('DelegatedTaskCard', () => { expect(onOpen).toHaveBeenCalledWith('child-1'); const queryOptions = queryOptionsMock.mock.calls[0]![1]; + // The server's refetchInterval drives polling; its absence means stop. expect(queryOptions.refetchInterval({ state: { data: undefined } })).toBe( - 2_000, + false, ); + expect( + queryOptions.refetchInterval({ + state: { data: { refetchInterval: 2_000 } }, + }), + ).toBe(2_000); expect(queryOptionsMock).toHaveBeenCalledWith( { taskId: 'child-1' }, expect.any(Object), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx index 8047cea93..5137a1de9 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx @@ -20,7 +20,9 @@ export function DelegatedTaskCard({ trpc.sandboxSession.byTaskId.queryOptions( { taskId }, { - refetchInterval: (query) => query.state.data?.refetchInterval ?? 2_000, + // The server omits refetchInterval once the run is settled — that is + // a stop signal, not missing data. Do not default it back to polling. + refetchInterval: (query) => query.state.data?.refetchInterval ?? false, }, ), ); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 6361bec27..394c2e116 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata, Viewport } from 'next'; import localFont from 'next/font/local'; import { DM_Sans } from 'next/font/google'; -import Script from 'next/script'; import { PRODUCT_NAME } from '@roomote/types'; @@ -118,12 +117,15 @@ export default async function RootLayout({ return ( + + {/* Must run synchronously before first paint: App Router queues + inline beforeInteractive Scripts until client bootstrap, which + flashes the wrong theme. */} + ({ queryOptions, }, }, + sessions: { + search: { + queryOptions: vi.fn((input, options) => ({ + queryKey: ['sessions', 'search', input], + queryFn: async () => null, + ...options, + })), + }, + }, }), })); diff --git a/apps/web/src/components/layout/CommandPalette.tsx b/apps/web/src/components/layout/CommandPalette.tsx index 99ff4caa6..e9972e68d 100644 --- a/apps/web/src/components/layout/CommandPalette.tsx +++ b/apps/web/src/components/layout/CommandPalette.tsx @@ -161,14 +161,10 @@ function AuthorizedCommandPalette() { { enabled: open }, ), ); - const sessionSearchOptions = trpc.sessions?.search?.queryOptions( + 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, - }; + { enabled: open }, + ); const { data: sessionResults } = useQuery(sessionSearchOptions); // Promote recently-visited tasks to the top of the list diff --git a/apps/web/src/components/sessions/SessionStatusBadge.tsx b/apps/web/src/components/sessions/SessionStatusBadge.tsx new file mode 100644 index 000000000..22b6fe94e --- /dev/null +++ b/apps/web/src/components/sessions/SessionStatusBadge.tsx @@ -0,0 +1,25 @@ +import { getSessionStatusLabel, type SessionStatus } from '@roomote/types'; + +import { Badge } from '@/components/system'; + +const STATUS_VARIANTS: Record< + SessionStatus, + 'success' | 'warning' | 'destructive' | 'secondary' +> = { + active: 'success', + needs_input: 'warning', + blocked: 'destructive', + ready: 'secondary', +}; + +function getSessionStatusVariant(status: string) { + return STATUS_VARIANTS[status as SessionStatus] ?? 'secondary'; +} + +export function SessionStatusBadge({ status }: { status: string }) { + return ( + + {getSessionStatusLabel(status)} + + ); +} diff --git a/apps/web/src/components/sessions/session-surfaces.ts b/apps/web/src/components/sessions/session-surfaces.ts new file mode 100644 index 000000000..f00ecf36c --- /dev/null +++ b/apps/web/src/components/sessions/session-surfaces.ts @@ -0,0 +1,49 @@ +/** + * One registry for every surface a Session can originate from (the + * sessions_source_surface_check constraint's value set). The filter options, + * labels, and brand icons all derive from here so a new surface shows up + * everywhere at once. + */ + +type SessionSurfaceBrandIcon = + | 'linear' + | 'github' + | 'gitlab' + | 'gitea' + | 'bitbucket' + | 'ado' + | 'discord' + | 'teams' + | 'telegram'; + +type SurfaceDescriptor = { + label: string; + brandIcon?: SessionSurfaceBrandIcon; +}; + +export const SESSION_SURFACES: Record = { + web: { label: 'Web' }, + api: { label: 'API' }, + slack: { label: 'Slack' }, + teams: { label: 'Teams', brandIcon: 'teams' }, + telegram: { label: 'Telegram', brandIcon: 'telegram' }, + discord: { label: 'Discord', brandIcon: 'discord' }, + linear: { label: 'Linear', brandIcon: 'linear' }, + github: { label: 'GitHub', brandIcon: 'github' }, + gitlab: { label: 'GitLab', brandIcon: 'gitlab' }, + gitea: { label: 'Gitea', brandIcon: 'gitea' }, + ado: { label: 'Azure DevOps', brandIcon: 'ado' }, + bitbucket: { label: 'Bitbucket', brandIcon: 'bitbucket' }, + system: { label: 'System' }, + automation: { label: 'Automation' }, +}; + +export function getSessionSurfaceLabel(surface: string): string { + return SESSION_SURFACES[surface]?.label ?? surface; +} + +export function getSessionSurfaceBrandIcon( + surface: string, +): SessionSurfaceBrandIcon | undefined { + return SESSION_SURFACES[surface]?.brandIcon; +} diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index ee1d48ab3..ef268a3c7 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -3,37 +3,28 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; type PersonalColorTheme = 'light' | 'dark' | 'system'; -const { - colorThemeState, - mindReaderModeState, - narrationModeState, - personalPreferencesState, -} = vi.hoisted(() => ({ - colorThemeState: { - colorTheme: 'system' as PersonalColorTheme, - isLoading: false, - isUpdating: false, - setColorTheme: vi.fn(), - }, - mindReaderModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, - narrationModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, - personalPreferencesState: { - preferences: { communicationsFastModeDefault: false }, - isLoading: false, - isUpdating: false, - setPreferences: vi.fn(), - }, -})); +const { colorThemeState, mindReaderModeState, narrationModeState } = vi.hoisted( + () => ({ + colorThemeState: { + colorTheme: 'system' as PersonalColorTheme, + isLoading: false, + isUpdating: false, + setColorTheme: vi.fn(), + }, + mindReaderModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + narrationModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + }), +); vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => colorThemeState, @@ -47,10 +38,6 @@ vi.mock('@/hooks/useMindReaderMode', () => ({ useMindReaderMode: () => mindReaderModeState, })); -vi.mock('@/hooks/usePersonalPreferences', () => ({ - usePersonalPreferences: () => personalPreferencesState, -})); - vi.mock('@/components/system', () => ({ Label: ({ children, @@ -136,9 +123,6 @@ describe('UserPreferencesSection', () => { narrationModeState.enabled = false; narrationModeState.isLoading = false; narrationModeState.isUpdating = false; - personalPreferencesState.preferences.communicationsFastModeDefault = false; - personalPreferencesState.isLoading = false; - personalPreferencesState.isUpdating = false; }); it('renders user preference controls with the current state', () => { @@ -165,12 +149,6 @@ describe('UserPreferencesSection', () => { ), ).toBeInTheDocument(); expect(screen.getByLabelText('Toggle narration mode')).toBeChecked(); - expect(screen.getByText('Fast response mode')).toHaveClass('font-semibold'); - expect( - screen.getByText( - 'Use fast responses by default for homepage prompts and linked Slack and Discord messages. GitHub, Teams, and Telegram are unaffected; `!fast` remains available in Slack.', - ), - ).toBeInTheDocument(); }); it('disables controls while the corresponding preference is loading or updating', () => { @@ -226,19 +204,4 @@ describe('UserPreferencesSection', () => { 'system', ); }); - - it('updates the fast response mode default', () => { - personalPreferencesState.preferences.communicationsFastModeDefault = true; - - render(); - - const toggle = screen.getByLabelText('Toggle fast response mode'); - expect(toggle).toBeChecked(); - - fireEvent.click(toggle); - - expect(personalPreferencesState.setPreferences).toHaveBeenCalledWith({ - communicationsFastModeDefault: false, - }); - }); }); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index a2689bc93..1f54c82fb 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -3,7 +3,6 @@ import { useColorTheme } from '@/hooks/useColorTheme'; import { useMindReaderMode } from '@/hooks/useMindReaderMode'; import { useNarrationMode } from '@/hooks/useNarrationMode'; -import { usePersonalPreferences } from '@/hooks/usePersonalPreferences'; import type { PersonalColorTheme } from '@/types/preferences'; import { @@ -47,14 +46,6 @@ export function UserPreferencesSection() { isUpdating: isNarrationModeUpdating, setEnabled: setNarrationModeEnabled, } = useNarrationMode(); - const { - preferences, - isLoading: isCommunicationsFastModeDefaultLoading, - isUpdating: isCommunicationsFastModeDefaultUpdating, - setPreferences, - } = usePersonalPreferences({ - errorMessage: 'Failed to update the communications fast mode default.', - }); const isThemeDisabled = isThemeLoading || isThemeUpdating; return ( @@ -122,30 +113,6 @@ export function UserPreferencesSection() {

- -
- - setPreferences({ communicationsFastModeDefault: enabled }) - } - /> -
-

- Fast response mode -

-

- Use fast responses by default for homepage prompts and linked - Slack and Discord messages. GitHub, Teams, and Telegram are - unaffected; `!fast` remains available in Slack. -

-
-
); diff --git a/apps/web/src/hooks/task-runs/index.ts b/apps/web/src/hooks/task-runs/index.ts index 918ff90f6..b51b354ee 100644 --- a/apps/web/src/hooks/task-runs/index.ts +++ b/apps/web/src/hooks/task-runs/index.ts @@ -1,5 +1,4 @@ export { useCancelTaskRun } from './useCancelTaskRun'; export { useCreateStandardTaskRun } from './useCreateStandardTaskRun'; export { useRetryFailedTaskStart } from './useRetryFailedTaskStart'; -export { useRouteHomeTask } from './useRouteHomeTask'; export { useStartFastSession } from './useStartFastSession'; diff --git a/apps/web/src/hooks/task-runs/useRouteHomeTask.ts b/apps/web/src/hooks/task-runs/useRouteHomeTask.ts deleted file mode 100644 index 494571ec6..000000000 --- a/apps/web/src/hooks/task-runs/useRouteHomeTask.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - type UseMutationOptions, - type UseMutationResult, - useMutation, -} from '@tanstack/react-query'; - -import type { RoutingDecision } from '@roomote/cloud-agents/server'; - -import { useTRPCClient } from '@/trpc/client'; - -type Variables = { - description: string; - images?: string[]; -}; - -type Options = Omit< - UseMutationOptions, - 'mutationFn' ->; - -export function useRouteHomeTask( - options: Options = {}, -): UseMutationResult { - const trpcClient = useTRPCClient(); - - return useMutation({ - mutationFn: (variables) => - trpcClient.taskRuns.routeHomeTask.mutate(variables), - ...options, - }); -} diff --git a/apps/web/src/hooks/useMarkSessionRead.ts b/apps/web/src/hooks/useMarkSessionRead.ts new file mode 100644 index 000000000..6ebd6d8d4 --- /dev/null +++ b/apps/web/src/hooks/useMarkSessionRead.ts @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useTRPCClient } from '@/trpc/client'; + +/** + * Advances the viewer's read cursor for a session on mount and whenever the + * window regains focus or visibility. The server resolves the latest external + * event itself, so this costs one tiny mutation instead of a timeline fetch. + */ +export function useMarkSessionRead(sessionId: string) { + const trpc = useTRPCClient(); + + useEffect(() => { + const markRead = () => { + if (document.visibilityState !== 'visible') return; + void trpc.sessions.markRead.mutate({ sessionId }); + }; + markRead(); + window.addEventListener('focus', markRead); + document.addEventListener('visibilitychange', markRead); + return () => { + window.removeEventListener('focus', markRead); + document.removeEventListener('visibilitychange', markRead); + }; + }, [sessionId, trpc]); +} diff --git a/apps/web/src/hooks/usePersonalPreferences.client.test.tsx b/apps/web/src/hooks/usePersonalPreferences.client.test.tsx index 5db66676c..4211f5502 100644 --- a/apps/web/src/hooks/usePersonalPreferences.client.test.tsx +++ b/apps/web/src/hooks/usePersonalPreferences.client.test.tsx @@ -150,7 +150,6 @@ describe('usePersonalPreferences', () => { colorTheme: 'system', mindReaderMode: false, narrationMode: false, - communicationsFastModeDefault: true, }); }); diff --git a/apps/web/src/hooks/usePersonalPreferences.ts b/apps/web/src/hooks/usePersonalPreferences.ts index b559d46cf..32a812ee1 100644 --- a/apps/web/src/hooks/usePersonalPreferences.ts +++ b/apps/web/src/hooks/usePersonalPreferences.ts @@ -51,10 +51,6 @@ function mergeResultForUpdatedFields( updates.narrationMode === undefined ? mergedPreferences.narrationMode : result.narrationMode, - communicationsFastModeDefault: - updates.communicationsFastModeDefault === undefined - ? mergedPreferences.communicationsFastModeDefault - : result.communicationsFastModeDefault, }; } @@ -85,12 +81,6 @@ function rollbackUpdatedFields( mergedPreferences.narrationMode === optimisticPreferences.narrationMode ? previousPreferences.narrationMode : mergedPreferences.narrationMode, - communicationsFastModeDefault: - updates.communicationsFastModeDefault !== undefined && - mergedPreferences.communicationsFastModeDefault === - optimisticPreferences.communicationsFastModeDefault - ? previousPreferences.communicationsFastModeDefault - : mergedPreferences.communicationsFastModeDefault, }; } diff --git a/apps/web/src/hooks/useRecentSessions.ts b/apps/web/src/hooks/useRecentSessions.ts index b109a171b..168880b0c 100644 --- a/apps/web/src/hooks/useRecentSessions.ts +++ b/apps/web/src/hooks/useRecentSessions.ts @@ -1,41 +1,43 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useLocalStorage } from 'usehooks-ts'; import { useAuthorizedUser } from './useUser'; -const MAX_RECENT_SESSIONS = 20; +const STORAGE_KEY_PREFIX = 'roomote-recent-sessions'; +const MAX_RECENT = 20; +type RecentEntry = { id: string; visitedAt: number }; + +/** + * Tracks recently visited session IDs in localStorage, mirroring + * useRecentTasks. Storage is scoped per signed-in user so account switches do + * not leak history. + */ 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 [entries, setEntries] = useLocalStorage( + `${STORAGE_KEY_PREFIX}:${userId}`, + [], + ); 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; + setEntries((prev) => { + const filtered = prev.filter((entry) => entry.id !== sessionId); + return [{ id: sessionId, visitedAt: Date.now() }, ...filtered].slice( + 0, + MAX_RECENT, + ); }); }, - [storageKey], + [setEntries], + ); + + const recentSessionIds = useMemo( + () => entries.map((entry) => entry.id), + [entries], ); return { recentSessionIds, recordVisit }; diff --git a/apps/web/src/lib/server/analytics/session-rows.test.ts b/apps/web/src/lib/server/analytics/session-rows.test.ts new file mode 100644 index 000000000..7c7e05763 --- /dev/null +++ b/apps/web/src/lib/server/analytics/session-rows.test.ts @@ -0,0 +1,146 @@ +import { + db, + eq, + inArray, + sessionFactory, + sessions, + sessionTasks, + taskFactory, + tasks, +} from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { getSessionAnalyticsRows } from './session-rows'; +import { formatAnalyticsDateTime } from './time-buckets'; + +describe('getSessionAnalyticsRows', () => { + const sessionIds: string[] = []; + const taskIds: string[] = []; + + // createdAt is database-generated, so backdate it after insert. + async function createSessionAt(createdAt: Date) { + const session = await sessionFactory.create(); + await db + .update(sessions) + .set({ createdAt }) + .where(eq(sessions.id, session.id)); + return session; + } + + afterEach(async () => { + if (sessionIds.length > 0) { + await db.delete(sessions).where(inArray(sessions.id, sessionIds)); + sessionIds.length = 0; + } + if (taskIds.length > 0) { + await db.delete(tasks).where(inArray(tasks.id, taskIds)); + taskIds.length = 0; + } + }); + + it('applies a day-aligned cutoff for finite time periods', async () => { + const now = new Date('2026-07-16T16:00:00.000Z'); + // Inside the window in every timezone. + const recentSession = await createSessionAt( + new Date('2026-07-15T12:00:00.000Z'), + ); + // After the naive `now - 7 * 24h` instant (2026-07-09T16:00:00Z) but + // before the day-aligned `startOfDay(subDays(now, 6))` cutoff, so it is + // excluded once buckets align with getExpectedBuckets. + const boundarySession = await createSessionAt( + new Date('2026-07-09T20:00:00.000Z'), + ); + // Far outside the window. + const oldSession = await createSessionAt( + new Date('2026-07-01T12:00:00.000Z'), + ); + sessionIds.push(recentSession.id, boundarySession.id, oldSession.id); + + const rows = await getSessionAnalyticsRows({} as UserAuthSuccess, 7, now); + const rowIds = new Set(rows.map((row) => row.id)); + + expect(rowIds.has(recentSession.id)).toBe(true); + expect(rowIds.has(boundarySession.id)).toBe(false); + expect(rowIds.has(oldSession.id)).toBe(false); + }); + + it('maps source surfaces to shared task-source labels and formats dates', async () => { + const slackSession = await sessionFactory.create({ + sourceSurface: 'slack', + sourceTrigger: 'message', + }); + const systemSession = await sessionFactory.create({ + sourceSurface: 'system', + }); + sessionIds.push(slackSession.id, systemSession.id); + + const rows = await getSessionAnalyticsRows( + {} as UserAuthSuccess, + 'all', + new Date('2026-07-16T16:00:00.000Z'), + ); + const slackRow = rows.find((row) => row.id === slackSession.id); + const systemRow = rows.find((row) => row.id === systemSession.id); + + expect(slackRow?.dimensions.source).toEqual({ + key: 'Slack', + label: 'Slack', + }); + expect(slackRow?.details.values.source).toBe('Slack'); + expect(slackRow?.details.values.date).toBe( + formatAnalyticsDateTime(slackSession.createdAt), + ); + expect(systemRow?.dimensions.source).toEqual({ + key: 'System', + label: 'System', + }); + }); + + it('counts session task executions with a single grouped join', async () => { + const sessionWithTasks = await sessionFactory.create(); + const sessionWithoutTasks = await sessionFactory.create(); + sessionIds.push(sessionWithTasks.id, sessionWithoutTasks.id); + + const firstTask = await taskFactory.create(); + const secondTask = await taskFactory.create(); + taskIds.push(firstTask.id, secondTask.id); + + await db.insert(sessionTasks).values([ + { + sessionId: sessionWithTasks.id, + taskId: firstTask.id, + origin: 'direct_launch', + }, + { + sessionId: sessionWithTasks.id, + taskId: secondTask.id, + origin: 'follow_up', + }, + ]); + + const rows = await getSessionAnalyticsRows( + {} as UserAuthSuccess, + 'all', + new Date('2026-07-16T16:00:00.000Z'), + ); + const withTasksRow = rows.find((row) => row.id === sessionWithTasks.id); + const withoutTasksRow = rows.find( + (row) => row.id === sessionWithoutTasks.id, + ); + + expect(withTasksRow?.dimensions.hasExecution).toEqual({ + key: 'yes', + label: 'yes', + }); + expect(withTasksRow?.details.values.hasExecution).toBe('yes'); + expect(withoutTasksRow?.dimensions.hasExecution).toEqual({ + key: 'no', + label: 'no', + }); + // Rows stay one-per-session despite the sessionTasks join. + expect(rows.filter((row) => row.id === sessionWithTasks.id)).toHaveLength( + 1, + ); + }); +}); diff --git a/apps/web/src/lib/server/analytics/session-rows.ts b/apps/web/src/lib/server/analytics/session-rows.ts index 9cc0a9d77..eba338701 100644 --- a/apps/web/src/lib/server/analytics/session-rows.ts +++ b/apps/web/src/lib/server/analytics/session-rows.ts @@ -1,3 +1,4 @@ +import type { TaskSurface } from '@roomote/types'; import { and, db, @@ -13,12 +14,25 @@ import type { TimePeriodFilter, UserAuthSuccess } from '@/types'; import { getUserDisplayName } from '@/lib'; import type { AnalyticsRow } from './types'; +import { createLabelBackedDimensionValue, mapTaskSource } from './dimensions'; +import { formatAnalyticsDateTime, getTimeCutoff } from './time-buckets'; export async function getSessionAnalyticsRows( _auth: UserAuthSuccess, timePeriod: TimePeriodFilter | undefined, now: Date, ): Promise { + const cutoff = getTimeCutoff(timePeriod, now); + + const executionCounts = db + .select({ + sessionId: sessionTasks.sessionId, + executionCount: sql`count(*)::int`.as('execution_count'), + }) + .from(sessionTasks) + .groupBy(sessionTasks.sessionId) + .as('execution_counts'); + const rows = await db .select({ id: sessions.id, @@ -27,24 +41,17 @@ export async function getSessionAnalyticsRows( ownerEmail: users.email, source: sessions.sourceSurface, ownerKind: sessions.ownerKind, - executionCount: sql`( - select count(*)::int from ${sessionTasks} - where ${sessionTasks.sessionId} = ${sessions.id} - )`, + executionCount: sql`coalesce(${executionCounts.executionCount}, 0)::int`, status: sessions.cachedStatus, createdAt: sessions.createdAt, }) .from(sessions) .leftJoin(users, eq(users.id, sessions.ownerUserId)) + .leftJoin(executionCounts, eq(executionCounts.sessionId, sessions.id)) .where( and( eq(sessions.visibility, 'visible'), - timePeriod && timePeriod !== 'all' - ? gte( - sessions.createdAt, - new Date(now.getTime() - timePeriod * 24 * 60 * 60 * 1000), - ) - : undefined, + cutoff ? gte(sessions.createdAt, cutoff) : undefined, ), ); @@ -54,6 +61,9 @@ export async function getSessionAnalyticsRows( 'System'; const status = row.status ?? 'ready'; const hasExecution = row.executionCount > 0 ? 'yes' : 'no'; + // Session source surfaces are the task surfaces (plus 'automation', which + // maps to the System source like other non-user-facing surfaces). + const sourceLabel = mapTaskSource(row.source as TaskSurface); return { id: row.id, timestamp: row.createdAt, @@ -61,16 +71,16 @@ export async function getSessionAnalyticsRows( dimensions: { user: { key: owner, label: owner }, status: { key: status, label: status.replace('_', ' ') }, - source: { key: row.source, label: row.source }, + source: createLabelBackedDimensionValue(sourceLabel), ownerKind: { key: row.ownerKind, label: row.ownerKind }, hasExecution: { key: hasExecution, label: hasExecution }, }, details: { id: row.id, values: { - date: row.createdAt.toISOString(), + date: formatAnalyticsDateTime(row.createdAt), user: owner, - source: row.source, + source: sourceLabel, status, ownerKind: row.ownerKind, hasExecution, diff --git a/apps/web/src/lib/server/auth-context.test.ts b/apps/web/src/lib/server/auth-context.test.ts index 6001b7b8a..240916449 100644 --- a/apps/web/src/lib/server/auth-context.test.ts +++ b/apps/web/src/lib/server/auth-context.test.ts @@ -210,20 +210,16 @@ describe('authorize', () => { expect(mockUpdateSet).not.toHaveBeenCalled(); }); - it('ignores stale metadata and hydrates disabled Sessions flags', async () => { + it('evaluates feature flags to an empty object while ignoring stale metadata', async () => { mockDeploymentFindFirst.mockResolvedValue({ - metadata: { suggestion_routing: true }, + metadata: { suggestion_routing: true, sessions_ui: true }, }); const result = await authorize(); expect(result.success).toBe(true); if (result.success) { - expect(result.featureFlags).toEqual({ - sessions_data: false, - sessions_ui: false, - sessions_comms: false, - }); + expect(result.featureFlags).toEqual({}); } }); diff --git a/apps/web/src/lib/server/fast-sessions.test.ts b/apps/web/src/lib/server/fast-sessions.test.ts index 6b67fa247..872961f4f 100644 --- a/apps/web/src/lib/server/fast-sessions.test.ts +++ b/apps/web/src/lib/server/fast-sessions.test.ts @@ -8,12 +8,10 @@ import { } from '@roomote/db/server'; import { - encodeFastSessionCursor, findAccessibleFastSession, getFastSessionById, getFastSessionTasks, getFastSessionMessagesSince, - getFastSessions, } from './fast-sessions'; async function createFastSession({ @@ -80,96 +78,7 @@ async function createFastMessage({ } describe('Fast session queries', () => { - it('lists only the current user sessions for a non-admin', async () => { - const owner = await userFactory.create(); - const otherUser = await userFactory.create(); - const older = await createFastSession({ - userId: owner.id, - conversationId: 'older', - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - }); - const newer = await createFastSession({ - userId: owner.id, - conversationId: 'newer', - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - }); - await createFastMessage({ - conversationId: newer.id, - eventId: 'newer:user', - turnSeq: 0, - role: 'user', - eventType: 'roomote_runtime.user_prompt', - }); - await createFastSession({ - userId: otherUser.id, - conversationId: 'other-user', - updatedAt: new Date('2026-01-03T00:00:00.000Z'), - }); - - const { sessions, nextCursor } = await getFastSessions({ - userId: owner.id, - isAdmin: false, - }); - - expect(sessions.map((session) => session.id)).toEqual([newer.id, older.id]); - expect(sessions[0]).toMatchObject({ - messageCount: 1, - ownerName: owner.name, - }); - expect(nextCursor).toBeNull(); - }); - - it('lists sessions across users for an admin', async () => { - const admin = await userFactory.create(); - const otherUser = await userFactory.create(); - const adminSession = await createFastSession({ - userId: admin.id, - conversationId: 'admin-session', - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - }); - const otherSession = await createFastSession({ - userId: otherUser.id, - conversationId: 'other-session', - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - }); - - const { sessions } = await getFastSessions({ - userId: admin.id, - isAdmin: true, - }); - - expect(sessions.map((session) => session.id)).toEqual( - expect.arrayContaining([adminSession.id, otherSession.id]), - ); - }); - - it('pages older sessions with a keyset cursor', async () => { - const owner = await userFactory.create(); - const oldest = await createFastSession({ - userId: owner.id, - conversationId: 'cursor-oldest', - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - }); - const middle = await createFastSession({ - userId: owner.id, - conversationId: 'cursor-middle', - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - }); - await createFastSession({ - userId: owner.id, - conversationId: 'cursor-newest', - updatedAt: new Date('2026-01-03T00:00:00.000Z'), - }); - - const { sessions } = await getFastSessions( - { userId: owner.id, isAdmin: false }, - { before: encodeFastSessionCursor(middle) }, - ); - - expect(sessions.map((session) => session.id)).toEqual([oldest.id]); - }); - - it('applies the same scope to detail lookups', async () => { + it('applies the caller scope to detail lookups', async () => { const owner = await userFactory.create(); const otherUser = await userFactory.create(); const session = await createFastSession({ @@ -317,9 +226,6 @@ describe('Fast session queries', () => { await expect( getFastSessionById(participantAuth, session.id), ).resolves.toMatchObject({ id: session.id }); - const { sessions: participantList } = - await getFastSessions(participantAuth); - expect(participantList.map((row) => row.id)).toContain(session.id); await expect( getFastSessionById({ userId: bystander.id, isAdmin: false }, session.id), diff --git a/apps/web/src/lib/server/fast-sessions.ts b/apps/web/src/lib/server/fast-sessions.ts index 41a17e5fa..1ab97a978 100644 --- a/apps/web/src/lib/server/fast-sessions.ts +++ b/apps/web/src/lib/server/fast-sessions.ts @@ -9,13 +9,11 @@ import { desc, eq, exists, - gte, fastAgentConversations, fastAgentMessages, llmUsageEvents, inArray, isNull, - lt, or, sql, taskRuns, @@ -24,7 +22,7 @@ import { } from '@roomote/db/server'; import type { FastAgentMessage } from '@roomote/db'; -import type { TimePeriodFilter, UserAuthSuccess } from '@/types'; +import type { UserAuthSuccess } from '@/types'; type FastSessionAuth = Pick; @@ -51,7 +49,6 @@ export type FastSessionMessage = Pick< | 'createdAt' >; -const FAST_SESSION_LIST_LIMIT = 200; const FAST_SESSION_TRANSCRIPT_MESSAGE_LIMIT = 1000; const fastSessionSelection = { @@ -258,88 +255,6 @@ export async function getFastSessionMessagesSince( return { messages, cursor }; } -export function encodeFastSessionCursor(row: { - updatedAt: Date; - id: string; -}): string { - return `${row.updatedAt.getTime()}:${row.id}`; -} - -function decodeFastSessionCursor(cursor: string | undefined) { - if (!cursor) { - return null; - } - - const separator = cursor.indexOf(':'); - if (separator <= 0) { - return null; - } - - const updatedAtMs = Number(cursor.slice(0, separator)); - const id = cursor.slice(separator + 1); - if (!Number.isFinite(updatedAtMs) || !id) { - return null; - } - - return { updatedAt: new Date(updatedAtMs), id }; -} - -export async function getFastSessions( - auth: FastSessionAuth, - options?: { - before?: string; - filterUserId?: string | null; - timePeriod?: TimePeriodFilter; - }, -) { - const cursor = decodeFastSessionCursor(options?.before); - - // Keyset pagination matching the (updatedAt desc, id desc) ordering. - const beforeCursor = cursor - ? or( - lt(fastAgentConversations.updatedAt, cursor.updatedAt), - and( - eq(fastAgentConversations.updatedAt, cursor.updatedAt), - lt(fastAgentConversations.id, cursor.id), - ), - ) - : undefined; - - const ownerFilter = options?.filterUserId - ? eq(fastAgentConversations.userId, options.filterUserId) - : undefined; - const timePeriod = options?.timePeriod ?? 'all'; - const timeFilter = - timePeriod === 'all' - ? undefined - : gte( - fastAgentConversations.updatedAt, - new Date(Date.now() - timePeriod * 24 * 60 * 60 * 1000), - ); - - const rows = await db - .select(fastSessionSelection) - .from(fastAgentConversations) - .innerJoin(users, eq(fastAgentConversations.userId, users.id)) - .where(and(fastSessionScope(auth), ownerFilter, timeFilter, beforeCursor)) - .orderBy( - desc(fastAgentConversations.updatedAt), - desc(fastAgentConversations.id), - ) - .limit(FAST_SESSION_LIST_LIMIT + 1); - - const sessions = rows.slice(0, FAST_SESSION_LIST_LIMIT); - const lastSession = sessions.at(-1); - - return { - sessions, - nextCursor: - rows.length > FAST_SESSION_LIST_LIMIT && lastSession - ? encodeFastSessionCursor(lastSession) - : null, - }; -} - export async function getFastSessionById( auth: FastSessionAuth, sessionId: string, diff --git a/apps/web/src/lib/server/sessions.test.ts b/apps/web/src/lib/server/sessions.test.ts index a0e9239ad..18ab55da4 100644 --- a/apps/web/src/lib/server/sessions.test.ts +++ b/apps/web/src/lib/server/sessions.test.ts @@ -10,6 +10,7 @@ import { import { findAccessibleSession, + getLatestExternalSessionEvent, getSessionById, getSessionForTask, getSessions, @@ -116,6 +117,67 @@ describe('unified Session queries', () => { expect(taskEvent).not.toHaveProperty('task.pullRequests'); }); + it('resolves the latest external event from visible messages only', async () => { + const owner = await userFactory.create(); + const other = 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: 'Unread Session', + fastConversationId: conversation!.id, + }); + await db.insert(fastAgentMessages).values([ + { + conversationId: conversation!.id, + eventId: 'visible-1', + turnId: 'turn-1', + turnSeq: 0, + ts: 100, + eventType: 'roomote_runtime.user_prompt', + role: 'user', + contentBlocks: [{ type: 'text', text: 'hello' }], + metadata: { userId: other.id, visibleInTranscript: true }, + payload: {}, + }, + { + conversationId: conversation!.id, + eventId: 'invisible-2', + turnId: 'turn-2', + turnSeq: 0, + ts: 200, + eventType: 'roomote_runtime.user_prompt', + role: 'user', + contentBlocks: [{ type: 'text', text: 'platform event' }], + metadata: { userId: other.id, visibleInTranscript: false }, + payload: {}, + }, + ]); + + // The invisible newer message must count for neither the unread max nor + // the read cursor, or the badge could never be cleared. + const latest = await getLatestExternalSessionEvent( + { userId: owner.id, isAdmin: false }, + session.id, + ); + expect(latest).toEqual({ at: 100, id: 'fast:visible-1' }); + + const list = await getSessions( + { userId: owner.id, isAdmin: false }, + { scope: 'all' }, + ); + const row = list.sessions.find((entry) => entry.id === session.id); + expect(row?.unread).toBe(true); + }); + it('excludes soft-deleted tasks from Session detail, timeline, and live status', async () => { const owner = await userFactory.create(); const session = await sessionFactory.create({ diff --git a/apps/web/src/lib/server/sessions.ts b/apps/web/src/lib/server/sessions.ts index 4f3f6c304..7328a6182 100644 --- a/apps/web/src/lib/server/sessions.ts +++ b/apps/web/src/lib/server/sessions.ts @@ -4,12 +4,14 @@ import { db, desc, deriveSessionStatus, + isSessionConversationResponding, eq, exists, fastAgentMessages, gte, ilike, inArray, + isNotNull, isNull, llmUsageEvents, lt, @@ -222,6 +224,7 @@ const baseSelection = { visibility: sessions.visibility, activityAt: sessions.activityAt, cachedStatus: sessions.cachedStatus, + respondingUntil: sessions.respondingUntil, archivedAt: sessions.archivedAt, createdAt: sessions.createdAt, updatedAt: sessions.updatedAt, @@ -312,7 +315,9 @@ async function hydrateSessionRows( )`, }) .from(sessions) - .where(inArray(sessions.id, ids)), + .where( + and(inArray(sessions.id, ids), isNotNull(sessions.fastConversationId)), + ), db .select({ sessionId: sessions.id, @@ -330,6 +335,10 @@ async function hydrateSessionRows( sql`${fastAgentMessages.metadata} ->> 'userId' IS NULL`, sql`${fastAgentMessages.metadata} ->> 'userId' <> ${auth.userId}`, ), + // Only events the transcript (and therefore the read cursor) can + // reach may count as unread, or invisible platform events would pin + // the badge forever. + sql`coalesce(${fastAgentMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, ), ) .groupBy(sessions.id), @@ -457,70 +466,99 @@ async function getSessionTasks(sessionId: string) { .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, - }; - }), + if (linked.length === 0) return []; + + // Four batched lookups regardless of task count; the per-task N+1 version + // multiplied badly under the session workspace's polling. + const taskIds = linked.map((task) => task.taskId); + const [latestRuns, artifactRows, pullRequestRows, usageRows] = + await Promise.all([ + db + .selectDistinctOn([taskRuns.taskId], { + taskId: taskRuns.taskId, + id: taskRuns.id, + status: taskRuns.status, + taskPhase: taskRuns.taskPhase, + error: taskRuns.error, + result: taskRuns.result, + }) + .from(taskRuns) + .where(inArray(taskRuns.taskId, taskIds)) + .orderBy(taskRuns.taskId, desc(taskRuns.id)), + db + .select({ + taskId: taskArtifacts.taskId, + id: taskArtifacts.id, + path: taskArtifacts.path, + artifactType: taskArtifacts.artifactType, + contentType: taskArtifacts.contentType, + size: taskArtifacts.size, + }) + .from(taskArtifacts) + .where(inArray(taskArtifacts.taskId, taskIds)) + .orderBy(desc(taskArtifacts.createdAt)), + db + .select({ + taskId: taskPullRequests.taskId, + id: taskPullRequests.id, + url: taskPullRequests.prUrl, + number: taskPullRequests.prNumber, + title: taskPullRequests.prTitle, + repository: taskPullRequests.repository, + status: taskPullRequests.status, + }) + .from(taskPullRequests) + .where(inArray(taskPullRequests.taskId, taskIds)), + db + .select({ + taskId: llmUsageEvents.taskId, + costMicroUsd: sql`coalesce(sum(${llmUsageEvents.costMicroUsd}), 0)::bigint`, + }) + .from(llmUsageEvents) + .where(inArray(llmUsageEvents.taskId, taskIds)) + .groupBy(llmUsageEvents.taskId), + ]); + + const latestRunByTask = new Map(latestRuns.map((run) => [run.taskId, run])); + const usageByTask = new Map( + usageRows.map((row) => [row.taskId, Number(row.costMicroUsd)]), ); + + return linked.map((task) => { + const latestRunRow = latestRunByTask.get(task.taskId); + const latestRun = latestRunRow + ? { + id: latestRunRow.id, + status: latestRunRow.status, + taskPhase: latestRunRow.taskPhase, + error: latestRunRow.error, + result: latestRunRow.result, + } + : null; + 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, + latestOutput, + inferenceCostMicroUsd: usageByTask.get(task.taskId) ?? 0, + artifacts: artifactRows + .filter((artifact) => artifact.taskId === task.taskId) + .map(({ taskId: _taskId, ...artifact }) => artifact), + pullRequests: pullRequestRows + .filter((pullRequest) => pullRequest.taskId === task.taskId) + .map(({ taskId: _taskId, ...pullRequest }) => pullRequest), + }; + }); } export async function getSessionById(auth: SessionAuth, sessionId: string) { @@ -531,8 +569,7 @@ export async function getSessionById(auth: SessionAuth, sessionId: string) { const [hydrated] = await hydrateSessionRows(auth, [session]); const sessionTaskDetails = await getSessionTasks(session.id); const liveStatus = deriveSessionStatus({ - conversationResponding: - Boolean(session.fastConversationId) && session.cachedStatus === 'active', + conversationResponding: isSessionConversationResponding(session), tasks: sessionTaskDetails.map((task) => ({ state: task.state, taskPhase: task.latestRun?.taskPhase ?? null, @@ -596,6 +633,61 @@ export async function getSessionTimeline( return { events, cursor: events.at(-1)?.at ?? since }; } +/** + * Latest event another participant produced in this session, matching the + * unread computation in hydrateSessionRows exactly: max of live task activity + * and visible non-own fast messages. Used by markRead so clients don't have + * to fetch a whole timeline to advance their read cursor. + */ +export async function getLatestExternalSessionEvent( + auth: SessionAuth, + sessionId: string, +): Promise<{ at: number; id: string } | null> { + const session = await findAccessibleSession(auth, sessionId); + if (!session) return null; + + const [[latestTask], fastRows] = await Promise.all([ + db + .select({ + taskId: tasks.id, + activityAt: sql`max(${tasks.activityAt})::bigint`, + }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and(eq(sessionTasks.sessionId, sessionId), isNull(tasks.deletedAt)), + ) + .groupBy(tasks.id) + .orderBy(desc(sql`max(${tasks.activityAt})`)) + .limit(1), + session.fastConversationId + ? db + .select({ id: fastAgentMessages.eventId, ts: fastAgentMessages.ts }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, session.fastConversationId), + or( + sql`${fastAgentMessages.metadata} ->> 'userId' IS NULL`, + sql`${fastAgentMessages.metadata} ->> 'userId' <> ${auth.userId}`, + ), + sql`coalesce(${fastAgentMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, + ), + ) + .orderBy(desc(fastAgentMessages.ts)) + .limit(1) + : Promise.resolve([]), + ]); + + const latestFast = fastRows[0]; + const taskAt = latestTask ? Number(latestTask.activityAt) * 1000 : 0; + const fastAt = latestFast ? Number(latestFast.ts) : 0; + if (taskAt === 0 && fastAt === 0) return null; + return fastAt >= taskAt + ? { at: fastAt, id: `fast:${latestFast!.id}` } + : { at: taskAt, id: `task:${latestTask!.taskId}:activity` }; +} + export async function getSessionForTask(auth: SessionAuth, taskId: string) { const [row] = await db .select({ sessionId: sessions.id, title: sessions.title }) diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index 7461a75ea..48ba8c268 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -199,9 +199,7 @@ export async function startFastSessionCommand( reasoningEffort: settings.reasoningEffort, }); - const unifiedSession = auth.featureFlags.sessions_ui - ? await getSessionForFastConversation(db, session.id) - : null; + const unifiedSession = await getSessionForFastConversation(db, session.id); return { sessionId: unifiedSession?.id ?? session.id, fastConversationId: session.id, 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 60aaa9b80..d7b438eec 100644 --- a/apps/web/src/trpc/commands/feature-flags/index.test.ts +++ b/apps/web/src/trpc/commands/feature-flags/index.test.ts @@ -52,27 +52,17 @@ function buildAuth(isAdmin: boolean): UserAuthSuccess { describe('feature-flags commands', () => { beforeEach(() => vi.clearAllMocks()); - it('returns the default-off Sessions rollout flags', async () => { + it('returns no experimental flags without a metadata lookup when the config is empty', 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(); + }); + + it('still rejects non-admin reads', async () => { + await expect(getExperimentalFlagsCommand(buildAuth(false))).rejects.toThrow( + 'Unauthorized', ); - expect(mockFindFirst).toHaveBeenCalledOnce(); }); it('rejects stale flags before metadata lookup or a database write', async () => { diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index c6ece35a2..226187af2 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -36,10 +36,6 @@ function normalizePersonalPreferences( typeof metadata.narration_mode === 'boolean' ? metadata.narration_mode : DEFAULT_PERSONAL_PREFERENCES.narrationMode, - communicationsFastModeDefault: - typeof metadata.communications_fast_mode_default === 'boolean' - ? metadata.communications_fast_mode_default - : DEFAULT_PERSONAL_PREFERENCES.communicationsFastModeDefault, }; } @@ -129,11 +125,6 @@ export async function updatePersonalPreferencesCommand( nextMetadataRecord.narration_mode = input.narrationMode; } - if (input.communicationsFastModeDefault !== undefined) { - nextMetadataRecord.communications_fast_mode_default = - input.communicationsFastModeDefault; - } - if (Object.keys(nextMetadataRecord).length === 0) { return getPersonalPreferencesCommand(auth); } diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts index 747d19157..9caaf53e2 100644 --- a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -20,7 +20,6 @@ describe('personal preferences', () => { ).resolves.toEqual( expect.objectContaining({ mindReaderMode: false, - communicationsFastModeDefault: true, }), ); }); @@ -65,49 +64,4 @@ describe('personal preferences', () => { }), ); }); - - it('persists communications fast mode updates', async () => { - const user = await userFactory.create(); - - await expect( - updatePersonalPreferencesCommand(buildAuth(user.id), { - communicationsFastModeDefault: true, - }), - ).resolves.toEqual( - expect.objectContaining({ communicationsFastModeDefault: true }), - ); - - const storedUser = await db.query.users.findFirst({ - where: eq(users.id, user.id), - columns: { metadata: true }, - }); - - expect(storedUser?.metadata).toEqual( - expect.objectContaining({ communications_fast_mode_default: true }), - ); - }); - - it('exposes a stored communications fast mode default', async () => { - const user = await userFactory.create({ - metadata: { communications_fast_mode_default: true }, - }); - - await expect( - getPersonalPreferencesCommand(buildAuth(user.id)), - ).resolves.toEqual( - expect.objectContaining({ communicationsFastModeDefault: true }), - ); - }); - - it('honors an explicit communications fast mode opt-out', async () => { - const user = await userFactory.create({ - metadata: { communications_fast_mode_default: false }, - }); - - await expect( - getPersonalPreferencesCommand(buildAuth(user.id)), - ).resolves.toEqual( - expect.objectContaining({ communicationsFastModeDefault: false }), - ); - }); }); diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts index d2e8ba13e..06a68d04b 100644 --- a/apps/web/src/trpc/commands/sessions/index.ts +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -1,10 +1,12 @@ import { z } from 'zod'; +import { SESSION_STATUSES } from '@roomote/types'; import { advanceSessionReadCursor, db } from '@roomote/db/server'; import { captureEvent } from '@roomote/telemetry/server'; import type { UserAuthSuccess } from '@/types'; import { findAccessibleSession, + getLatestExternalSessionEvent, getSessionById, getSessionForTask, getSessions, @@ -18,7 +20,7 @@ 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(), + status: z.enum(SESSION_STATUSES).optional(), user: z.string().nullish(), repository: z.string().nullish(), environment: z.string().nullish(), @@ -33,14 +35,34 @@ export const sessionsListInputSchema = z.object({ export async function markSessionReadCommand( auth: UserAuthSuccess, - input: { sessionId: string; throughEventAt: number; throughEventId: string }, + input: { + sessionId: string; + throughEventAt?: number; + throughEventId?: string; + }, ) { - if (!(await findAccessibleSession(auth, input.sessionId))) return null; + if ( + input.throughEventAt !== undefined && + input.throughEventId !== undefined + ) { + if (!(await findAccessibleSession(auth, input.sessionId))) return null; + return advanceSessionReadCursor(db, { + sessionId: input.sessionId, + userId: auth.userId, + eventAt: input.throughEventAt, + eventId: input.throughEventId, + }); + } + + // No explicit cursor: resolve the latest external event server-side so + // clients can mark a session read without fetching its timeline. + const latest = await getLatestExternalSessionEvent(auth, input.sessionId); + if (!latest) return null; return advanceSessionReadCursor(db, { sessionId: input.sessionId, userId: auth.userId, - eventAt: input.throughEventAt, - eventId: input.throughEventId, + eventAt: latest.at, + eventId: latest.id, }); } diff --git a/apps/web/src/trpc/commands/task-runs/index.test.ts b/apps/web/src/trpc/commands/task-runs/index.test.ts index 2e2c5ff7a..275ef2d72 100644 --- a/apps/web/src/trpc/commands/task-runs/index.test.ts +++ b/apps/web/src/trpc/commands/task-runs/index.test.ts @@ -27,19 +27,22 @@ const { })); vi.mock('@roomote/cloud-agents/server', () => ({ - buildSlackRoutingContext: vi.fn(), + DeploymentReadOnlyError: class DeploymentReadOnlyError extends Error {}, enqueueTask: (...args: unknown[]) => mockEnqueueTask(...args), getTaskUrl: vi.fn(() => 'https://roomote.test/tasks/task-123'), - routeTask: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + sessionTasks: { taskId: 'sessionTasks.taskId' }, db: { query: { tasks: { findFirst: vi.fn(async () => null), }, + sessionTasks: { + findFirst: vi.fn(async () => null), + }, taskRuns: { findFirst: vi.fn(async () => null), findMany: vi.fn(async () => []), diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts index add345283..37819e471 100644 --- a/apps/web/src/trpc/commands/task-runs/index.ts +++ b/apps/web/src/trpc/commands/task-runs/index.ts @@ -12,12 +12,9 @@ import { resolveEvalHarnessSelection, } from '@roomote/types'; import { - type RoutingDecision, - buildSlackRoutingContext, DeploymentReadOnlyError, enqueueTask, getTaskUrl, - routeTask, } from '@roomote/cloud-agents/server'; import { captureTaskSettled } from '@roomote/telemetry/server'; import { @@ -36,7 +33,7 @@ import { import { SlackNotifier, settleSlackLiveTaskCardForRun } from '@roomote/slack'; import type { UserAuthSuccess } from '@/types'; -import { Env, getArtifactById, getRepositories } from '@/lib/server'; +import { getArtifactById, getRepositories } from '@/lib/server'; import { resolveEnvironmentSourceControlProvider, resolveSelectedRepositorySourceControlProvider, @@ -363,42 +360,6 @@ async function notifySourceTaskArtifactBuild({ }); } -export async function routeHomeTaskCommand( - auth: UserAuthSuccess, - input: { - description: string; - images?: string[]; - }, -): Promise { - try { - const trimmedDescription = input.description.trim(); - - if (trimmedDescription.length === 0) { - return { - status: 'fallback', - reason: 'Task description is required for auto routing.', - }; - } - - const routingContext = await buildSlackRoutingContext({ - userId: auth.userId, - taskDescription: trimmedDescription, - ...(input.images?.length ? { images: input.images } : {}), - apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, - }); - - return await routeTask(routingContext); - } catch (error) { - console.error(error); - - return { - status: 'fallback', - reason: - error instanceof Error ? error.message : 'An unknown error occurred.', - }; - } -} - export async function createStandardTaskRunCommand( auth: UserAuthSuccess, input: CreateStandardTaskRunInput, @@ -465,12 +426,10 @@ 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; + const linkedSession = await db.query.sessionTasks.findFirst({ + where: eq(sessionTasks.taskId, launchResult.taskId), + columns: { sessionId: true }, + }); try { await notifySourceTaskArtifactBuild({ 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 8fd780327..a60d259ca 100644 --- a/apps/web/src/trpc/commands/tasks/__tests__/delete.test.ts +++ b/apps/web/src/trpc/commands/tasks/__tests__/delete.test.ts @@ -7,7 +7,10 @@ const { mockTouchSessionActivity, tasksTable, taskArtifactsTable, + sessionsTable, + sessionTasksTable, deleteCalls, + updateCalls, transactionSpy, } = vi.hoisted(() => ({ mockDeleteArtifactsBatch: vi.fn(), @@ -21,7 +24,13 @@ const { path: 'taskArtifacts.path', version: 'taskArtifacts.version', }, + sessionsTable: { id: 'sessions.id', archivedAt: 'sessions.archivedAt' }, + sessionTasksTable: { + sessionId: 'sessionTasks.sessionId', + taskId: 'sessionTasks.taskId', + }, deleteCalls: [] as unknown[], + updateCalls: [] as unknown[], transactionSpy: vi.fn(), })); @@ -36,12 +45,29 @@ const artifactRows = [ }, ]; +// Rows the fake sessionTasks SELECT returns; tests override to simulate a +// session left with no remaining live tasks. +const remainingSessionTaskRows: Array<{ taskId: string }> = []; + const fakeTx = { select: () => ({ - from: (table: unknown) => ({ - where: async () => - table === taskArtifactsTable ? artifactRows : taskRows, - }), + from: (table: unknown) => { + const rowsFor = () => + table === taskArtifactsTable + ? artifactRows + : table === sessionTasksTable + ? remainingSessionTaskRows + : taskRows; + return { + where: () => + Object.assign(Promise.resolve(rowsFor()), { + limit: async () => rowsFor(), + }), + innerJoin: () => ({ + where: () => ({ limit: async () => rowsFor() }), + }), + }; + }, }), delete: (table: unknown) => ({ where: (condition: unknown) => { @@ -49,11 +75,14 @@ const fakeTx = { return Promise.resolve(); }, }), - update: () => ({ - set: () => ({ - where: () => ({ - returning: async () => taskRows, - }), + update: (table: unknown) => ({ + set: (values: unknown) => ({ + where: (condition: unknown) => { + updateCalls.push({ table, values, condition }); + return Object.assign(Promise.resolve(), { + returning: async () => taskRows, + }); + }, }), }), }; @@ -67,10 +96,13 @@ vi.mock('@roomote/db/server', () => ({ }, tasks: tasksTable, taskArtifacts: taskArtifactsTable, + sessions: sessionsTable, + sessionTasks: sessionTasksTable, markTaskStartParallelCountsEndedAtForTaskIds: mockMarkParallelCounts, getSessionForTask: mockGetSessionForTask, touchSessionActivity: mockTouchSessionActivity, and: (...conditions: unknown[]) => ({ and: conditions }), + eq: (left: unknown, right: unknown) => ({ eq: [left, right] }), inArray: (column: unknown, values: unknown) => ({ inArray: [column, values], }), @@ -94,10 +126,14 @@ describe('deleteTasksCommand', () => { beforeEach(() => { vi.clearAllMocks(); deleteCalls.length = 0; + updateCalls.length = 0; + remainingSessionTaskRows.length = 0; mockDeleteArtifactsBatch.mockResolvedValue({ deleted: 1, errors: 0 }); mockGetSessionForTask.mockResolvedValue({ id: 'session-1', activityAt: 100, + fastConversationId: null, + archivedAt: null, }); }); @@ -131,4 +167,43 @@ describe('deleteTasksCommand', () => { 100, ); }); + + it('archives a session left with no live tasks', async () => { + await deleteTasksCommand(auth, { taskIds: ['task-1'] }); + + const archiveUpdate = updateCalls.find( + (call) => (call as { table: unknown }).table === sessionsTable, + ) as { values: { archivedAt: unknown } } | undefined; + expect(archiveUpdate).toBeDefined(); + expect(archiveUpdate!.values.archivedAt).toBeInstanceOf(Date); + }); + + it('keeps a session visible when live tasks remain', async () => { + remainingSessionTaskRows.push({ taskId: 'task-2' }); + + await deleteTasksCommand(auth, { taskIds: ['task-1'] }); + + expect( + updateCalls.some( + (call) => (call as { table: unknown }).table === sessionsTable, + ), + ).toBe(false); + }); + + it('never archives a session that has a fast conversation', async () => { + mockGetSessionForTask.mockResolvedValue({ + id: 'session-1', + activityAt: 100, + fastConversationId: 'fast-1', + archivedAt: null, + }); + + await deleteTasksCommand(auth, { taskIds: ['task-1'] }); + + expect( + updateCalls.some( + (call) => (call as { table: unknown }).table === sessionsTable, + ), + ).toBe(false); + }); }); diff --git a/apps/web/src/trpc/commands/tasks/delete.ts b/apps/web/src/trpc/commands/tasks/delete.ts index 3c41429d9..8b9717968 100644 --- a/apps/web/src/trpc/commands/tasks/delete.ts +++ b/apps/web/src/trpc/commands/tasks/delete.ts @@ -6,8 +6,11 @@ import { touchSessionActivity, taskArtifacts, and, + eq, inArray, isNull, + sessions, + sessionTasks, } from '@roomote/db/server'; import { deleteArtifactsBatch } from '@/lib/server'; @@ -107,6 +110,29 @@ export async function deleteTasksCommand( } for (const session of affectedSessions.values()) { await touchSessionActivity(tx, session.id, session.activityAt); + + // A session whose last task was just deleted (and that has no Fast + // conversation) would linger on the dashboard as an empty card carrying + // the deleted task's title. Archive it; users can unarchive. + if (!session.fastConversationId && !session.archivedAt) { + const [remaining] = await tx + .select({ taskId: sessionTasks.taskId }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and( + eq(sessionTasks.sessionId, session.id), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + if (!remaining) { + await tx + .update(sessions) + .set({ archivedAt: endedAt, updatedAt: endedAt }) + .where(eq(sessions.id, session.id)); + } + } } return { diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 0d0cc7922..ce84311aa 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -127,7 +127,6 @@ import { syncRepositoriesCommand, } from '../commands/source-control'; import { - routeHomeTaskCommand, createStandardTaskRunCommand, cancelTaskRunCommand, retryFailedTaskStartCommand, @@ -1060,17 +1059,6 @@ export const appRouter = createRouter({ startTaskGoalCommand(auth, input), ), - routeHomeTask: protectedProcedure - .input( - z.object({ - description: z.string(), - images: z.array(z.string()).optional(), - }), - ) - .mutation(({ ctx: { auth }, input }) => - routeHomeTaskCommand(auth, input), - ), - createStandardTask: protectedProcedure .input( z.object({ @@ -1477,14 +1465,12 @@ export const appRouter = createRouter({ colorTheme: z.enum(PERSONAL_COLOR_THEMES).optional(), mindReaderMode: z.boolean().optional(), narrationMode: z.boolean().optional(), - communicationsFastModeDefault: z.boolean().optional(), }) .refine( (input) => input.colorTheme !== undefined || input.mindReaderMode !== undefined || - input.narrationMode !== undefined || - input.communicationsFastModeDefault !== undefined, + input.narrationMode !== undefined, { message: 'Expected at least one personal preference to update.', }, @@ -2858,8 +2844,8 @@ export const appRouter = createRouter({ markRead: protectedProcedure .input( sessionIdInputSchema.extend({ - throughEventAt: z.number().nonnegative(), - throughEventId: z.string().min(1), + throughEventAt: z.number().nonnegative().optional(), + throughEventId: z.string().min(1).optional(), }), ) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index 2ce9cecd1..dbcf508e0 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -16,7 +16,6 @@ export interface PersonalPreferences { colorTheme: PersonalColorTheme; mindReaderMode: boolean; narrationMode: boolean; - communicationsFastModeDefault: boolean; } export type PersonalPreferencesUpdate = Partial; @@ -25,5 +24,4 @@ export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', mindReaderMode: false, narrationMode: false, - communicationsFastModeDefault: true, }; diff --git a/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts b/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts index 5f73b7e83..fd7c2f661 100644 --- a/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts +++ b/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts @@ -135,7 +135,7 @@ describe('Slack live task card', () => { }); expect(renderedCard(4)).toEqual({ status: 'complete', - output: 'Task completed.', + output: 'Ready.', }); }); @@ -585,7 +585,7 @@ describe('Slack live task card', () => { expect(renderedCard(1)).toMatchObject({ status: 'error', - output: 'Task canceled.', + output: 'Stopped.', }); }); @@ -640,7 +640,7 @@ describe('Slack live task card', () => { expect(renderedCard(2)).toMatchObject({ status: 'error', - output: 'The task stopped because of an error.', + output: 'Stopped because of an error.', }); // The next run of the task flips the card back to in progress. @@ -715,7 +715,7 @@ describe('Slack live task card', () => { // The last narration line is never promoted to the final result. expect(renderedCard(2)).toMatchObject({ status: 'complete', - output: 'Task completed.', + output: 'Ready.', }); }); @@ -772,7 +772,7 @@ describe('Slack live task card', () => { expect(renderedCard(2)).toEqual({ status: 'complete', - output: 'Task completed.', + output: 'Ready.', }); expect(renderedCard(3)).toEqual({ status: 'in_progress', @@ -830,7 +830,7 @@ describe('Slack live task card', () => { expect(renderedCard(1)).toEqual({ status: 'complete', - output: 'Task completed.', + output: 'Ready.', }); expect(renderedCard(2)).toEqual({ status: 'complete', @@ -898,7 +898,7 @@ describe('Slack live task card', () => { expect(mocks.renderCard).toHaveBeenCalledOnce(); expect(renderedCard(1)).toEqual({ status: 'error', - output: 'The task stopped because of an error.', + output: 'Stopped because of an error.', }); }); @@ -914,7 +914,7 @@ describe('Slack live task card', () => { expect(mocks.renderCard).toHaveBeenCalledTimes(2); expect(renderedCard(1)).toEqual({ status: 'error', - output: 'The task stopped because of an error.', + output: 'Stopped because of an error.', }); expect(renderedCard(2)).toEqual(renderedCard(1)); }); @@ -952,7 +952,7 @@ describe('Slack live task card', () => { expect(mocks.renderCard).toHaveBeenCalledOnce(); expect(renderedCard(1)).toEqual({ status: 'error', - output: 'The task stopped because of an error.', + output: 'Stopped because of an error.', }); }); diff --git a/apps/worker/src/callbacks/slack-live-task-stream.ts b/apps/worker/src/callbacks/slack-live-task-stream.ts index ccaab506b..5256f6e73 100644 --- a/apps/worker/src/callbacks/slack-live-task-stream.ts +++ b/apps/worker/src/callbacks/slack-live-task-stream.ts @@ -1,6 +1,6 @@ import { RunStatus } from '@roomote/types'; import { - SLACK_LIVE_TASK_CARD_MESSAGES, + SLACK_SESSION_LIVE_TASK_CARD_MESSAGES, type SlackTaskStreamStatus, } from '@roomote/slack/client'; import { sdk, type TaskRun } from '@roomote/sdk/client'; @@ -395,7 +395,7 @@ export async function finishSlackLiveTaskStream( state.status = 'complete'; if (!state.awaitingInput) { state.message = - state.finalMessage ?? SLACK_LIVE_TASK_CARD_MESSAGES.completed; + state.finalMessage ?? SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.completed; state.provisionalCompletion = state.finalMessage === undefined; } await renderCard(taskRun, context, { settle: true }); @@ -413,7 +413,7 @@ export async function finishSlackLiveTaskStream( } state.status = 'complete'; state.message = - state.finalMessage ?? SLACK_LIVE_TASK_CARD_MESSAGES.completed; + state.finalMessage ?? SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.completed; state.provisionalCompletion = false; await renderCard(taskRun, context, { settle: true }); return; @@ -421,7 +421,7 @@ export async function finishSlackLiveTaskStream( if (status === RunStatus.Canceled) { state.status = 'error'; - state.message = SLACK_LIVE_TASK_CARD_MESSAGES.canceled; + state.message = SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.canceled; state.provisionalCompletion = false; await renderCard(taskRun, context, { settle: true }); return; @@ -430,7 +430,7 @@ export async function finishSlackLiveTaskStream( // A failed turn is not the end of the task: the workspace is retained and // the next run (a follow-up or a retry) keeps driving this same card. state.status = 'error'; - state.message = SLACK_LIVE_TASK_CARD_MESSAGES.failed; + state.message = SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.failed; state.provisionalCompletion = false; await renderCard(taskRun, context, { settle: true }); } diff --git a/apps/worker/src/run-task/__tests__/runtime-envelope-subscription.test.ts b/apps/worker/src/run-task/__tests__/runtime-envelope-subscription.test.ts index aad35c9ae..01dc3c679 100644 --- a/apps/worker/src/run-task/__tests__/runtime-envelope-subscription.test.ts +++ b/apps/worker/src/run-task/__tests__/runtime-envelope-subscription.test.ts @@ -531,7 +531,7 @@ describe('subscribeHarnessCallbacks', () => { 'runtime-session-transient', { type: 'completion', - text: 'Task completed.', + text: 'Ready.', ts: expect.any(Number), provisional: true, }, diff --git a/apps/worker/src/run-task/subscribe-harness-callbacks.ts b/apps/worker/src/run-task/subscribe-harness-callbacks.ts index 1cd68f388..3f29c0f55 100644 --- a/apps/worker/src/run-task/subscribe-harness-callbacks.ts +++ b/apps/worker/src/run-task/subscribe-harness-callbacks.ts @@ -10,7 +10,7 @@ import { stripLlmCitationArtifacts, } from '@roomote/types'; import { type DequeuedTaskRun, sdk } from '@roomote/sdk/client'; -import { SLACK_LIVE_TASK_CARD_MESSAGES } from '@roomote/slack/client'; +import { SLACK_SESSION_LIVE_TASK_CARD_MESSAGES } from '@roomote/slack/client'; import type { Harness } from '../sandbox-server'; import type { HarnessInferenceUsageEvent } from '../sandbox-server/lib/harness'; @@ -314,7 +314,7 @@ export function subscribeHarnessCallbacks({ for (const callbackTaskId of filteredCompletionCallbackIds) { await forwardCallbackEvent(callbackTaskId, { type: 'completion', - text: SLACK_LIVE_TASK_CARD_MESSAGES.completed, + text: SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.completed, ts: Date.now(), provisional: true, }); diff --git a/packages/cloud-agents/package.json b/packages/cloud-agents/package.json index c95b22a87..5bdcd044c 100644 --- a/packages/cloud-agents/package.json +++ b/packages/cloud-agents/package.json @@ -72,7 +72,6 @@ "@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 7ec01d5bb..d0407c0c8 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -4,7 +4,6 @@ // 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'), @@ -36,6 +35,7 @@ import { taskPullRequests, taskRunEvents, deploymentSettings, + fastAgentConversations, users, environments, environmentRepositoryMappings, @@ -913,25 +913,6 @@ 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({ @@ -1121,7 +1102,16 @@ describe('enqueueTask snapshot resume', () => { it('preserves Fast parent routing and communication isolation across resume', async () => { const userId = await createUser(); - const fastAgentSessionId = '11111111-1111-4111-8111-111111111111'; + const fastAgentSessionId = crypto.randomUUID(); + // The Session linkage created at enqueue references the Fast conversation + // row, so the parent conversation must exist. + await db.insert(fastAgentConversations).values({ + id: fastAgentSessionId, + userId, + surface: 'slack', + workspaceId: 'T123', + conversationId: fastAgentSessionId, + }); const fastAgentParent = { sessionId: fastAgentSessionId, conversation: { @@ -1209,8 +1199,16 @@ describe('enqueueTask snapshot resume', () => { it('recovers Fast parent isolation from an older ancestor in a resume chain', async () => { const userId = await createUser(); + const ancestorFastSessionId = crypto.randomUUID(); + await db.insert(fastAgentConversations).values({ + id: ancestorFastSessionId, + userId, + surface: 'slack', + workspaceId: 'T123', + conversationId: ancestorFastSessionId, + }); const fastAgentParent = { - sessionId: '22222222-2222-4222-8222-222222222222', + sessionId: ancestorFastSessionId, conversation: { surface: 'slack' as const, workspaceId: 'T123', 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 3c24e63c1..f49cfee0c 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 @@ -14,10 +14,6 @@ import { 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'; @@ -65,10 +61,6 @@ export interface FastAgentConversationRepository { }): Promise; } -async function sessionsDataEnabled(): Promise { - return evaluateDeploymentFeatureFlag(FeatureFlag.SessionsData); -} - function buildIdentityKey(conversation: FastAgentConversation): string { return `${conversation.surface}:${conversation.workspaceId}:${conversation.conversationId}`; } @@ -168,7 +160,6 @@ 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))`, @@ -233,9 +224,7 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = .where(eq(fastAgentConversations.id, record.id)) .returning(); - if (createSession) { - await ensureSessionForFastConversation(tx, updated?.id ?? record.id); - } + await ensureSessionForFastConversation(tx, updated?.id ?? record.id); return loadConversationRecord(tx, updated?.id ?? record.id); }); @@ -308,7 +297,6 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = return; } - const touchSession = await sessionsDataEnabled(); await db.transaction(async (tx) => { const conversationId = await resolveCanonicalId(tx, requestedId); await tx.execute( @@ -325,25 +313,19 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = if (!updated) { throw new Error('Fast conversation was not found.'); } - if (touchSession) { - const session = await getSessionForFastConversation( + const session = await getSessionForFastConversation(tx, conversationId); + if (session) { + await touchSessionActivity( tx, - conversationId, + session.id, + Math.floor(Date.now() / 1000), + { recomputeStatus: false }, ); - 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( @@ -385,33 +367,28 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = .update(fastAgentConversations) .set({ updatedAt: sql`now()` }) .where(eq(fastAgentConversations.id, conversationId)); - if (touchSession) { - const session = await getSessionForFastConversation( + const session = await getSessionForFastConversation(tx, conversationId); + if (session) { + await touchSessionActivity( tx, - conversationId, + session.id, + Math.floor(message.ts / 1000), + { recomputeStatus: false }, ); - 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, - }); - } + 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 71b85606a..cedb87177 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 @@ -29,10 +29,6 @@ import { isBrainEnabled, touchSessionActivity, } from '@roomote/db/server'; -import { - evaluateDeploymentFeatureFlag, - FeatureFlag, -} from '@roomote/feature-flags/server'; import { Env } from '@roomote/env'; import { z } from 'zod'; @@ -141,6 +137,10 @@ 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; +// Generous ceiling on one fast-agent turn: long enough for delegation-heavy +// responses, short enough that a crashed turn self-heals the session status. +const FAST_RESPONDING_LEASE_MS = 15 * 60 * 1000; + async function setFastSessionResponding( fastConversationId: string, responding: boolean, @@ -148,7 +148,9 @@ async function setFastSessionResponding( const session = await getSessionForFastConversation(db, fastConversationId); if (!session) return; await touchSessionActivity(db, session.id, Math.floor(Date.now() / 1000), { - conversationResponding: responding, + respondingUntil: responding + ? new Date(Date.now() + FAST_RESPONDING_LEASE_MS) + : null, }); } @@ -1639,16 +1641,10 @@ 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; + linkedSession = await getSessionForTask(db, task.taskId); } catch (error) { console.warn( `[sessions] Failed to resolve Session kickoff link: ${formatErrorForLog(error)}`, @@ -1658,13 +1654,11 @@ export async function answerFastAgentQuestion({ ? `${Env.R_APP_URL}/sessions/${linkedSession.id}?task=${task.taskId}` : task.taskUrl; const message = [ - sessionCommsEnabled - ? `Preparing workspace…\n\n${args.kickoffMessage}` - : args.kickoffMessage, + `Preparing workspace…\n\n${args.kickoffMessage}`, destinationUrl && !task.taskLinkRendered && !args.kickoffMessage.includes(destinationUrl) - ? `[${sessionCommsEnabled ? 'Open in Roomote' : 'Open the task'}](${destinationUrl})` + ? `[Open in Roomote](${destinationUrl})` : undefined, ] .filter((part): part is string => Boolean(part)) diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts index 91600c8ec..cabd7b6be 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts @@ -7,6 +7,8 @@ import { fastAgentMessages, isNull, lt, + or, + sessions, sql, } from '@roomote/db/server'; import { @@ -57,6 +59,7 @@ export async function refreshFastAgentSessionTitle({ where: eq(fastAgentConversations.id, sessionId), columns: { id: true, + title: true, titleEditedByUserAt: true, llmTitleCheckpoint: true, }, @@ -117,16 +120,36 @@ export async function refreshFastAgentSessionTitle({ return; } - await db - .update(fastAgentConversations) - .set({ title, llmTitleCheckpoint: checkpoint }) - .where( - and( - eq(fastAgentConversations.id, sessionId), - isNull(fastAgentConversations.titleEditedByUserAt), - lt(fastAgentConversations.llmTitleCheckpoint, checkpoint), - ), - ); + await db.transaction(async (tx) => { + const [updatedConversation] = await tx + .update(fastAgentConversations) + .set({ title, llmTitleCheckpoint: checkpoint }) + .where( + and( + eq(fastAgentConversations.id, sessionId), + isNull(fastAgentConversations.titleEditedByUserAt), + lt(fastAgentConversations.llmTitleCheckpoint, checkpoint), + ), + ) + .returning({ id: fastAgentConversations.id }); + if (!updatedConversation) return; + + // Keep the unified Session's title in step with the generated + // conversation title, but never clobber a manual Session rename: only + // overwrite the creation placeholder or a previous generated title. + await tx + .update(sessions) + .set({ title, updatedAt: new Date() }) + .where( + and( + eq(sessions.fastConversationId, sessionId), + or( + eq(sessions.title, 'New session'), + eq(sessions.title, conversation.title ?? ''), + ), + ), + ); + }); } catch (error) { console.error( `[Fast Agent] Failed to refresh session title session=${sessionId}: ${formatErrorForLog(error)}`, diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 5466f6fa7..87cebd2ff 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -72,10 +72,6 @@ import { resolveWorkspaceRepositoryProviders, sql, } from '@roomote/db/server'; -import { - evaluateDeploymentFeatureFlag, - FeatureFlag, -} from '@roomote/feature-flags/server'; import { type Redis, getRedis } from '@roomote/redis'; import { captureActivationTaskCreated, @@ -1390,10 +1386,6 @@ 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); const requestedExistingTask = input.existingTaskId @@ -1645,16 +1637,14 @@ async function enqueueFreshLaunch( }); if (activeRun) { - if (sessionsDataEnabled) { - await ensureSessionForTask(tx, { - taskId: existingTask.id, - fastConversationId: - getFastAgentParentFromPayload(taskWithHarnessOverrides.payload) - ?.sessionId ?? null, - origin: 'follow_up', - existingTaskReused: true, - }); - } + await ensureSessionForTask(tx, { + taskId: existingTask.id, + fastConversationId: + getFastAgentParentFromPayload(taskWithHarnessOverrides.payload) + ?.sessionId ?? null, + origin: 'follow_up', + existingTaskReused: true, + }); return { taskRun: activeRun, createdRun: false, reusedTask: true }; } @@ -1704,21 +1694,19 @@ 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), - }); - } + 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 = { @@ -1836,19 +1824,14 @@ 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' }, - }, - ); - } + 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 diff --git a/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql b/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql deleted file mode 100644 index 6e7a31327..000000000 --- a/packages/db/drizzle/0065_enable_fast_mode_for_existing_users.sql +++ /dev/null @@ -1,5 +0,0 @@ -UPDATE "users" -SET - "metadata" = "metadata" || '{"communications_fast_mode_default": true}'::jsonb, - "updated_at" = now() -WHERE NOT ("metadata" ? 'communications_fast_mode_default'); diff --git a/packages/db/drizzle/0065_sessions_responding_until.sql b/packages/db/drizzle/0065_sessions_responding_until.sql new file mode 100644 index 000000000..e19bf24b2 --- /dev/null +++ b/packages/db/drizzle/0065_sessions_responding_until.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" ADD COLUMN "responding_until" timestamp; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0065_snapshot.json b/packages/db/drizzle/meta/0065_snapshot.json new file mode 100644 index 000000000..6cd246c7e --- /dev/null +++ b/packages/db/drizzle/meta/0065_snapshot.json @@ -0,0 +1,13902 @@ +{ + "id": "e7f83099-576b-405c-b8f8-88fc5759e295", + "prevId": "bcb782aa-16ce-4df2-b0ec-8ba470bab175", + "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 + }, + "brain_enabled": { + "name": "brain_enabled", + "type": "boolean", + "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 + }, + "responding_until": { + "name": "responding_until", + "type": "timestamp", + "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 368455f41..1b4c270e8 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -460,8 +460,8 @@ { "idx": 65, "version": "7", - "when": 1787908038380, - "tag": "0065_enable_fast_mode_for_existing_users", + "when": 1787922913068, + "tag": "0065_sessions_responding_until", "breakpoints": true } ] diff --git a/packages/db/src/lib/__tests__/sessions.test.ts b/packages/db/src/lib/__tests__/sessions.test.ts index a4f88241f..4a5b6384f 100644 --- a/packages/db/src/lib/__tests__/sessions.test.ts +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -114,16 +114,52 @@ describe('session helpers', () => { createdSessionIds.push(session.id); const active = await touchSessionActivity(db, session.id, 200, { - conversationResponding: true, + respondingUntil: new Date(Date.now() + 60_000), }); const ready = await touchSessionActivity(db, session.id, 201, { - conversationResponding: false, + respondingUntil: null, }); expect(active.cachedStatus).toBe('active'); expect(ready.cachedStatus).toBe('ready'); }); + it('honors the stored responding lease when recomputing without options', async () => { + const session = await sessionFactory.create({ cachedStatus: 'ready' }); + createdSessionIds.push(session.id); + + await touchSessionActivity(db, session.id, 200, { + respondingUntil: new Date(Date.now() + 60_000), + }); + const recomputed = await touchSessionActivity(db, session.id, 201); + + expect(recomputed.cachedStatus).toBe('active'); + }); + + it('treats an expired responding lease as not responding', async () => { + const session = await sessionFactory.create({ cachedStatus: 'active' }); + createdSessionIds.push(session.id); + + await touchSessionActivity(db, session.id, 200, { + respondingUntil: new Date(Date.now() - 1_000), + }); + const recomputed = await touchSessionActivity(db, session.id, 201); + + expect(recomputed.cachedStatus).toBe('ready'); + }); + + it('skips the write when nothing changed', async () => { + const session = await sessionFactory.create({ + activityAt: 200, + cachedStatus: 'ready', + }); + createdSessionIds.push(session.id); + + const touched = await touchSessionActivity(db, session.id, 100); + + expect(touched.updatedAt).toEqual(session.updatedAt); + }); + it('recomputes cached status from linked tasks while touching activity', async () => { const session = await sessionFactory.create({ activityAt: 100, diff --git a/packages/db/src/lib/sessions.ts b/packages/db/src/lib/sessions.ts index b53c51461..ac3af2fb0 100644 --- a/packages/db/src/lib/sessions.ts +++ b/packages/db/src/lib/sessions.ts @@ -57,18 +57,33 @@ export function deriveSessionStatus(input: SessionStatusInput): SessionStatus { return 'ready'; } +export function isSessionConversationResponding( + session: Pick, + now: Date = new Date(), +): boolean { + return ( + session.respondingUntil !== null && + session.respondingUntil.getTime() > now.getTime() + ); +} + export async function touchSessionActivity( dbOrTx: DatabaseOrTransaction, sessionId: string, at: number, options: { - conversationResponding?: boolean; + /** + * Set (a future timestamp) or clear (null) the conversation-responding + * lease. When omitted, the stored lease decides whether the conversation + * counts as responding during status recomputation. + */ + respondingUntil?: Date | null; recomputeStatus?: boolean; } = {}, ): Promise { return runInTransactionIfAvailable(dbOrTx, async (tx) => { const [lockedSession] = await tx - .select({ id: sessions.id }) + .select() .from(sessions) .where(eq(sessions.id, sessionId)) .for('update'); @@ -77,46 +92,70 @@ export async function touchSessionActivity( throw new Error(`Session ${sessionId} does not exist.`); } - return refreshLockedSession(tx, sessionId, at, options); + return refreshLockedSession(tx, lockedSession, at, options); }); } async function refreshLockedSession( tx: DatabaseOrTransaction, - sessionId: string, + lockedSession: Session, at: number, - options: { conversationResponding?: boolean; recomputeStatus?: boolean }, + options: { respondingUntil?: Date | null; 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 respondingUntil = + options.respondingUntil !== undefined + ? options.respondingUntil + : lockedSession.respondingUntil; + + let cachedStatus = lockedSession.cachedStatus; + if (options.recomputeStatus !== false) { + 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, lockedSession.id), + isNull(tasks.deletedAt), + ), + ) + .orderBy(tasks.id, desc(taskRuns.id)); + + cachedStatus = deriveSessionStatus({ + conversationResponding: isSessionConversationResponding({ + respondingUntil, + }), + tasks: linkedTasks, + }); + } + + const nothingChanged = + at <= lockedSession.activityAt && + cachedStatus === lockedSession.cachedStatus && + options.respondingUntil === undefined; + if (nothingChanged) { + return lockedSession; + } const [updated] = await tx .update(sessions) .set({ activityAt: sql`GREATEST(${sessions.activityAt}, ${at})`, - ...(options.recomputeStatus === false - ? {} - : { - cachedStatus: deriveSessionStatus({ - conversationResponding: options.conversationResponding ?? false, - tasks: linkedTasks, - }), - }), + cachedStatus, + respondingUntil, updatedAt: new Date(), }) - .where(eq(sessions.id, sessionId)) + .where(eq(sessions.id, lockedSession.id)) .returning(); - if (!updated) throw new Error(`Session ${sessionId} does not exist.`); + if (!updated) { + throw new Error(`Session ${lockedSession.id} does not exist.`); + } return updated; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3f663032d..46943b2ec 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -81,6 +81,7 @@ import type { McpToolAccessMode, FastAgentSurface, ReasoningEffort, + SessionStatus, } from '@roomote/types'; import { DEFAULT_TASK_ARTIFACT_TYPE } from '@roomote/types'; @@ -3530,7 +3531,7 @@ export const automationsRelations = relations(automations, ({ many }) => ({ export type SessionOwnerKind = 'user' | 'automation' | 'system'; export type SessionSourceSurface = TaskSurface | FastAgentSurface; -export type SessionStatus = 'active' | 'needs_input' | 'blocked' | 'ready'; +export type { SessionStatus }; export type SessionTaskOrigin = | 'direct_launch' | 'fast_delegation' @@ -3576,6 +3577,10 @@ export const sessions = pgTable( .$type(), activityAt: bigint('activity_at', { mode: 'number' }).notNull(), cachedStatus: text('cached_status').$type(), + // Fast-conversation responding lease: while this is in the future, status + // recomputation treats the conversation as actively responding. TTL-based + // so a crashed turn self-heals instead of pinning the session 'active'. + respondingUntil: timestamp('responding_until'), archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/packages/feature-flags/src/__tests__/config.test.ts b/packages/feature-flags/src/__tests__/config.test.ts index ad1ebcacd..ff26bd12b 100644 --- a/packages/feature-flags/src/__tests__/config.test.ts +++ b/packages/feature-flags/src/__tests__/config.test.ts @@ -1,21 +1,26 @@ import { describe, expect, it } from 'vitest'; -import { FEATURE_FLAG_CONFIG } from '../config'; +import { + DEPLOYMENT_METADATA_BOOLEAN_CONFIG, + FEATURE_FLAG_CONFIG, +} from '../config'; import { FeatureFlag } from '../types'; describe('feature flags', () => { - 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 }), - }), - ); + it('defines no active flags now that the Sessions rollout is unconditional', () => { + expect(FeatureFlag).toEqual({}); + expect(FEATURE_FLAG_CONFIG).toEqual({}); + }); + + it('retains the deployment-control metadata descriptors', () => { + expect(Object.keys(DEPLOYMENT_METADATA_BOOLEAN_CONFIG).sort()).toEqual([ + 'anonymous_analytics_enabled', + 'deployment_disabled', + ]); + for (const descriptor of Object.values( + DEPLOYMENT_METADATA_BOOLEAN_CONFIG, + )) { + expect(descriptor.kind).toBe('deployment-control'); + } }); }); diff --git a/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts b/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts index 324d148e6..c5578e060 100644 --- a/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts +++ b/packages/feature-flags/src/__tests__/evaluateFlagFromMetadata.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { coerceToBoolean, evaluateFeatureFlagsFromMetadata, normalizeMetadataRecord, } from '../index'; +import type { FeatureFlag } from '../types'; describe('generic feature flag evaluation', () => { it('keeps generic boolean coercion behavior', () => { @@ -23,34 +24,164 @@ describe('generic feature flag evaluation', () => { }); }); - it('ignores stale metadata and keeps Sessions flags disabled by default', () => { + it('evaluates to an empty object with the empty flag config', () => { + expect(evaluateFeatureFlagsFromMetadata({})).toEqual({}); expect( evaluateFeatureFlagsFromMetadata({ - slack_eval_launcher: true, - show_debug_ui_setting: true, - suggestion_routing: true, - visual_proof_auto_screencast: true, - background_subagents: true, - opencode_code_mode: true, + stale_flag: true, + sessions_data: true, + sessions_ui: 'true', }), - ).toEqual({ - sessions_data: false, - sessions_ui: false, - sessions_comms: false, - }); + ).toEqual({}); + }); +}); + +describe('flag machinery with a synthetic config', () => { + const SYNTHETIC_FLAG = 'synthetic_flag' as unknown as FeatureFlag; + const OVERRIDDEN_FLAG = 'overridden_flag' as unknown as FeatureFlag; + + async function importWithSyntheticConfig() { + vi.resetModules(); + vi.doMock('../types', async (importOriginal) => ({ + ...(await importOriginal()), + FeatureFlag: { + SyntheticFlag: 'synthetic_flag', + OverriddenFlag: 'overridden_flag', + }, + })); + vi.doMock('../config', async (importOriginal) => ({ + ...(await importOriginal()), + FEATURE_FLAG_CONFIG: { + synthetic_flag: { + defaultValue: false, + metadataKey: 'synthetic_flag', + legacyMetadataKeys: ['synthetic_flag_legacy'], + description: 'Synthetic flag for machinery tests', + group: 'testing', + }, + overridden_flag: { + defaultValue: false, + override: () => true, + }, + }, + })); + return import('../index'); + } + + afterEach(() => { + vi.doUnmock('../types'); + vi.doUnmock('../config'); + vi.resetModules(); + }); + + it('falls back to the default value when metadata has no key', async () => { + const { evaluateFeatureFlagFromMetadata } = + await importWithSyntheticConfig(); + + expect(evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, {})).toBe(false); + expect(evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, null)).toBe(false); + }); + + it('reads and coerces the primary metadata key', async () => { + const { evaluateFeatureFlagFromMetadata } = + await importWithSyntheticConfig(); + + expect( + evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, { synthetic_flag: true }), + ).toBe(true); + expect( + evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, { + synthetic_flag: 'true', + }), + ).toBe(true); + expect( + evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, { + synthetic_flag: false, + }), + ).toBe(false); }); - it('evaluates each Sessions rollout flag independently', () => { + it('honors legacy metadata keys with the primary key winning', async () => { + const { evaluateFeatureFlagFromMetadata } = + await importWithSyntheticConfig(); + expect( - evaluateFeatureFlagsFromMetadata({ - sessions_data: true, - sessions_ui: 'true', - sessions_comms: false, + evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, { + synthetic_flag_legacy: true, }), - ).toEqual({ - sessions_data: true, - sessions_ui: true, - sessions_comms: false, + ).toBe(true); + expect( + evaluateFeatureFlagFromMetadata(SYNTHETIC_FLAG, { + synthetic_flag: false, + synthetic_flag_legacy: true, + }), + ).toBe(false); + }); + + it('lets earlier metadata sources shadow later ones', async () => { + const { evaluateFeatureFlagFromMetadataSources } = + await importWithSyntheticConfig(); + + expect( + evaluateFeatureFlagFromMetadataSources(SYNTHETIC_FLAG, [ + { synthetic_flag: true }, + { synthetic_flag: false }, + ]), + ).toBe(true); + expect( + evaluateFeatureFlagFromMetadataSources(SYNTHETIC_FLAG, [ + {}, + { synthetic_flag: true }, + ]), + ).toBe(true); + }); + + it('applies a config override regardless of metadata', async () => { + const { evaluateFeatureFlagFromMetadata } = + await importWithSyntheticConfig(); + + expect( + evaluateFeatureFlagFromMetadata(OVERRIDDEN_FLAG, { + overridden_flag: false, + }), + ).toBe(true); + }); + + it('rejects unknown flags', async () => { + const { evaluateFeatureFlagFromMetadata } = + await importWithSyntheticConfig(); + + expect(() => + evaluateFeatureFlagFromMetadata('missing_flag' as never, {}), + ).toThrow('Unknown feature flag: missing_flag'); + }); + + it('evaluates every configured flag from one metadata record', async () => { + const { evaluateFeatureFlagsFromMetadata: evaluateAll } = + await importWithSyntheticConfig(); + + expect(evaluateAll({ synthetic_flag: true })).toEqual({ + synthetic_flag: true, + overridden_flag: true, + }); + }); + + it('classifies configured metadata keys as feature flags', async () => { + const { getBooleanMetadataDescriptorByKey } = + await importWithSyntheticConfig(); + + expect(getBooleanMetadataDescriptorByKey('synthetic_flag')).toEqual({ + kind: 'feature-flag', + description: 'Synthetic flag for machinery tests', + group: 'testing', + }); + expect( + getBooleanMetadataDescriptorByKey('synthetic_flag_legacy').kind, + ).toBe('feature-flag'); + expect(getBooleanMetadataDescriptorByKey('unrelated_key')).toEqual({ + kind: 'legacy', + description: null, + group: null, }); }); }); diff --git a/packages/feature-flags/src/config.ts b/packages/feature-flags/src/config.ts index 9c94f5f4d..107bb2cd6 100644 --- a/packages/feature-flags/src/config.ts +++ b/packages/feature-flags/src/config.ts @@ -1,25 +1,6 @@ import type { FeatureFlagConfigMap, MetadataBooleanDescriptor } from './types'; -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', - }, -}; +export const FEATURE_FLAG_CONFIG: FeatureFlagConfigMap = {}; /** * Non-feature-flag boolean deployment metadata that is still actively read in diff --git a/packages/feature-flags/src/server/deployment.test.ts b/packages/feature-flags/src/server/deployment.test.ts index c0b15e8d3..26a22e1ed 100644 --- a/packages/feature-flags/src/server/deployment.test.ts +++ b/packages/feature-flags/src/server/deployment.test.ts @@ -1,3 +1,5 @@ +import type { FeatureFlag } from '../types'; + const { findDeploymentSettings } = vi.hoisted(() => ({ findDeploymentSettings: vi.fn(), })); @@ -12,6 +14,19 @@ vi.mock('@roomote/db/server', () => ({ eq: vi.fn(), })); +// The production flag config is empty; the deployment evaluator machinery is +// exercised against a synthetic flag config instead. +vi.mock('../config', async (importOriginal) => ({ + ...(await importOriginal()), + FEATURE_FLAG_CONFIG: { + synthetic_flag: { defaultValue: false }, + other_flag: { defaultValue: false }, + }, +})); + +const SYNTHETIC_FLAG = 'synthetic_flag' as unknown as FeatureFlag; +const OTHER_FLAG = 'other_flag' as unknown as FeatureFlag; + describe('evaluateDeploymentFeatureFlag', () => { beforeEach(() => { vi.resetModules(); @@ -25,22 +40,22 @@ describe('evaluateDeploymentFeatureFlag', () => { it('reuses deployment metadata until the bounded cache expires', async () => { findDeploymentSettings - .mockResolvedValueOnce({ metadata: { sessions_data: true } }) - .mockResolvedValueOnce({ metadata: { sessions_data: false } }); + .mockResolvedValueOnce({ metadata: { synthetic_flag: true } }) + .mockResolvedValueOnce({ metadata: { synthetic_flag: false } }); const { evaluateDeploymentFeatureFlag } = await import('./deployment'); - await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + await expect(evaluateDeploymentFeatureFlag(SYNTHETIC_FLAG)).resolves.toBe( true, ); - await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + await expect(evaluateDeploymentFeatureFlag(SYNTHETIC_FLAG)).resolves.toBe( true, ); expect(findDeploymentSettings).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(30_001); - await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + await expect(evaluateDeploymentFeatureFlag(SYNTHETIC_FLAG)).resolves.toBe( false, ); expect(findDeploymentSettings).toHaveBeenCalledTimes(2); @@ -48,15 +63,15 @@ describe('evaluateDeploymentFeatureFlag', () => { it('coalesces concurrent metadata reads', async () => { findDeploymentSettings.mockResolvedValue({ - metadata: { sessions_data: true, sessions_comms: true }, + metadata: { synthetic_flag: true, other_flag: true }, }); const { evaluateDeploymentFeatureFlag } = await import('./deployment'); await expect( Promise.all([ - evaluateDeploymentFeatureFlag('sessions_data'), - evaluateDeploymentFeatureFlag('sessions_comms'), + evaluateDeploymentFeatureFlag(SYNTHETIC_FLAG), + evaluateDeploymentFeatureFlag(OTHER_FLAG), ]), ).resolves.toEqual([true, true]); expect(findDeploymentSettings).toHaveBeenCalledTimes(1); @@ -64,19 +79,19 @@ describe('evaluateDeploymentFeatureFlag', () => { it('refreshes immediately after explicit invalidation', async () => { findDeploymentSettings - .mockResolvedValueOnce({ metadata: { sessions_data: false } }) - .mockResolvedValueOnce({ metadata: { sessions_data: true } }); + .mockResolvedValueOnce({ metadata: { synthetic_flag: false } }) + .mockResolvedValueOnce({ metadata: { synthetic_flag: true } }); const { evaluateDeploymentFeatureFlag, invalidateDeploymentFeatureFlagCache, } = await import('./deployment'); - await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + await expect(evaluateDeploymentFeatureFlag(SYNTHETIC_FLAG)).resolves.toBe( false, ); invalidateDeploymentFeatureFlagCache(); - await expect(evaluateDeploymentFeatureFlag('sessions_data')).resolves.toBe( + await expect(evaluateDeploymentFeatureFlag(SYNTHETIC_FLAG)).resolves.toBe( true, ); diff --git a/packages/feature-flags/src/types.ts b/packages/feature-flags/src/types.ts index 649b2f060..350a410db 100644 --- a/packages/feature-flags/src/types.ts +++ b/packages/feature-flags/src/types.ts @@ -2,11 +2,7 @@ * Feature flag types and configuration */ -export const FeatureFlag = { - SessionsData: 'sessions_data', - SessionsUi: 'sessions_ui', - SessionsComms: 'sessions_comms', -} as const; +export const FeatureFlag = {} as const; export type FeatureFlag = (typeof FeatureFlag)[keyof typeof FeatureFlag]; diff --git a/packages/slack/package.json b/packages/slack/package.json index d4aea38fb..1789320c0 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -23,7 +23,6 @@ "@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/__tests__/fast-agent-live-task-launcher.test.ts b/packages/slack/src/__tests__/fast-agent-live-task-launcher.test.ts index 7c0ab08e0..10f352059 100644 --- a/packages/slack/src/__tests__/fast-agent-live-task-launcher.test.ts +++ b/packages/slack/src/__tests__/fast-agent-live-task-launcher.test.ts @@ -1,5 +1,6 @@ const mocks = vi.hoisted(() => ({ enqueueTask: vi.fn(), + getSessionForTask: vi.fn(), getSlackLiveTaskStreamData: vi.fn(), setSlackLiveTaskStreamData: vi.fn(), postMessage: vi.fn(), @@ -60,6 +61,15 @@ vi.mock('@roomote/cloud-agents/server', () => ({ }, })); +vi.mock('@roomote/db/server', () => ({ + db: {}, + getSessionForTask: mocks.getSessionForTask, +})); + +vi.mock('@roomote/env', () => ({ + Env: { R_APP_URL: 'https://roomote.example' }, +})); + vi.mock('../live-task-stream', () => ({ buildSlackLiveTaskTitle: (prompt: string) => prompt, getSlackLiveTaskStreamData: mocks.getSlackLiveTaskStreamData, @@ -94,6 +104,7 @@ describe('createFastAgentSlackLiveTaskLauncher', () => { beforeEach(() => { vi.clearAllMocks(); mocks.enqueueTask.mockResolvedValue(undefined); + mocks.getSessionForTask.mockResolvedValue(null); mocks.getSlackLiveTaskStreamData.mockResolvedValue(null); mocks.postMessageDetailed.mockResolvedValue({ ts: 'card-ts' }); mocks.postMessage.mockResolvedValue('fallback-ts'); @@ -105,11 +116,11 @@ describe('createFastAgentSlackLiveTaskLauncher', () => { const taskLinkFallback = { channel: 'C123', thread_ts: '100.001', - text: 'Open the task: https://roomote.example/task/task-1', + text: 'Open in Roomote: https://roomote.example/task/task-1', blocks: [ { type: 'markdown', - text: '[Open the task](https://roomote.example/task/task-1)', + text: '[Open in Roomote](https://roomote.example/task/task-1)', }, ], unfurl_links: false, @@ -135,18 +146,18 @@ describe('createFastAgentSlackLiveTaskLauncher', () => { expect(mocks.postMessageDetailed).toHaveBeenCalledWith({ channel: 'C123', thread_ts: '100.001', - text: 'Starting task…\n', + text: 'Preparing workspace…\n', blocks: [ expect.objectContaining({ type: 'task_card', task_id: 'roomote-task-task-1', - title: 'Starting task…', + title: 'Preparing workspace…', status: 'in_progress', sources: [ { type: 'url', url: 'https://roomote.example/task/task-1', - text: 'View task', + text: 'Open in Roomote', }, ], }), @@ -180,6 +191,34 @@ describe('createFastAgentSlackLiveTaskLauncher', () => { }); }); + it('links the card to the session when the task already has one', async () => { + mocks.getSessionForTask.mockResolvedValue({ id: 'session-1' }); + + await createLauncher()({ + prompt: 'Add a regression test', + environmentId: null, + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff: vi.fn(), + }); + + const sessionUrl = 'https://roomote.example/sessions/session-1?task=task-1'; + expect(mocks.postMessageDetailed).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + sources: [ + { type: 'url', url: sessionUrl, text: 'Open in Roomote' }, + ], + }), + ], + }), + ); + expect(mocks.setSlackLiveTaskStreamData).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ taskUrl: sessionUrl }), + ); + }); + it('reuses an existing card instead of posting a second one', async () => { mocks.getSlackLiveTaskStreamData.mockResolvedValue({ teamId: 'T123', @@ -292,7 +331,7 @@ describe('createFastAgentSlackLiveTaskLauncher', () => { { type: 'url', url: 'https://roomote.example/task/task-1', - text: 'View task', + text: 'Open in Roomote', }, ], }), diff --git a/packages/slack/src/__tests__/settle-live-task-card.test.ts b/packages/slack/src/__tests__/settle-live-task-card.test.ts index 591a6cf29..cb4c81c03 100644 --- a/packages/slack/src/__tests__/settle-live-task-card.test.ts +++ b/packages/slack/src/__tests__/settle-live-task-card.test.ts @@ -82,7 +82,7 @@ describe('settleSlackLiveTaskCardForRun', () => { elements: [ { type: 'rich_text_section', - elements: [{ type: 'text', text: 'Task canceled.' }], + elements: [{ type: 'text', text: 'Stopped.' }], }, ], }, diff --git a/packages/slack/src/client.ts b/packages/slack/src/client.ts index 76dc36e66..48fb816e9 100644 --- a/packages/slack/src/client.ts +++ b/packages/slack/src/client.ts @@ -2,7 +2,7 @@ export { SlackNotifier } from './slack-notifier'; export type { SlackTaskStreamStatus } from './slack-notifier'; // Workers only describe the card state they want shown; the control plane // builds the blocks and holds the workspace credential. -export { SLACK_LIVE_TASK_CARD_MESSAGES } from './live-task-card-blocks'; +export { SLACK_SESSION_LIVE_TASK_CARD_MESSAGES } from './live-task-card-blocks'; export { convertMarkdownToSlack, diff --git a/packages/slack/src/fast-agent-live-task-launcher.ts b/packages/slack/src/fast-agent-live-task-launcher.ts index f23431910..ca0145490 100644 --- a/packages/slack/src/fast-agent-live-task-launcher.ts +++ b/packages/slack/src/fast-agent-live-task-launcher.ts @@ -6,13 +6,8 @@ import { 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 { @@ -28,7 +23,6 @@ type SlackLiveTaskCardNotifier = Pick< 'postMessage' | 'postMessageDetailed' | 'updateMessage' >; -export const STARTING_TASK_TITLE = 'Starting task…'; export const PREPARING_WORKSPACE_TITLE = 'Preparing workspace…'; function describeError(error: unknown): string { @@ -38,7 +32,7 @@ function describeError(error: unknown): string { /** * Fast delegation launcher that also posts a native task card (a * `task_card` block) in the parent thread. The card opens as a bare - * "Starting task…" placeholder; once the sandbox is up the worker renders + * "Preparing workspace…" placeholder; once the sandbox is up the worker renders * the generated title and then re-renders the whole card through * chat.update for the task's lifetime, so it always shows the latest state. * @@ -57,11 +51,8 @@ export function createFastAgentSlackLiveTaskLauncher( ): LaunchFastAgentTask { const { slack, ...launcherParams } = params; - const postTaskLink = async ( - taskUrl: string, - sessionMode = false, - ): Promise => { - const label = sessionMode ? 'Open in Roomote' : 'Open the task'; + const postTaskLink = async (taskUrl: string): Promise => { + const label = 'Open in Roomote'; try { await slack.postMessage({ channel: launcherParams.channelId, @@ -84,16 +75,10 @@ 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; + const linkedSession = await getSessionForTask(db, taskRun.taskId); destinationUrl = linkedSession ? `${Env.R_APP_URL}/sessions/${linkedSession.id}?task=${taskRun.taskId}` : context.taskUrl; @@ -109,10 +94,9 @@ export function createFastAgentSlackLiveTaskLauncher( thread_ts: launcherParams.threadTs, ...buildSlackLiveTaskCardBlocks({ taskUpdateId, - title: sessionMode ? PREPARING_WORKSPACE_TITLE : STARTING_TASK_TITLE, + title: PREPARING_WORKSPACE_TITLE, status: 'in_progress', taskUrl: destinationUrl, - sessionMode, }), unfurl_links: false, unfurl_media: false, @@ -128,7 +112,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(destinationUrl, sessionMode); + await postTaskLink(destinationUrl); return; } @@ -143,7 +127,6 @@ export function createFastAgentSlackLiveTaskLauncher( threadTs: launcherParams.threadTs, title: buildSlackLiveTaskTitle(context.prompt), taskUrl: destinationUrl, - ...(sessionMode ? { sessionMode: true } : {}), }); } catch (error) { console.error( @@ -165,15 +148,10 @@ export function createFastAgentSlackLiveTaskLauncher( ts: messageTs, message: buildSlackLiveTaskCardBlocks({ taskUpdateId, - title: sessionMode - ? PREPARING_WORKSPACE_TITLE - : STARTING_TASK_TITLE, + title: PREPARING_WORKSPACE_TITLE, status: 'error', - message: sessionMode - ? SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.trackingUnavailable - : SLACK_LIVE_TASK_CARD_MESSAGES.trackingUnavailable, + message: SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.trackingUnavailable, taskUrl: destinationUrl, - sessionMode, }), }); } catch (updateError) { @@ -182,7 +160,7 @@ export function createFastAgentSlackLiveTaskLauncher( ); } if (!settled) { - await postTaskLink(destinationUrl, sessionMode); + await postTaskLink(destinationUrl); } } }; diff --git a/packages/slack/src/live-task-card-blocks.ts b/packages/slack/src/live-task-card-blocks.ts index df83b16de..cab93ed75 100644 --- a/packages/slack/src/live-task-card-blocks.ts +++ b/packages/slack/src/live-task-card-blocks.ts @@ -8,14 +8,6 @@ export const SLACK_LIVE_TASK_CARD_MESSAGE_MAX_CHARS = 4000; /** Terminal messages shared by the worker and the control plane so a card * settled from either side reads the same. */ -export const SLACK_LIVE_TASK_CARD_MESSAGES = { - completed: 'Task completed.', - canceled: 'Task canceled.', - failed: 'The task stopped because of an error.', - trackingUnavailable: - '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.', @@ -32,7 +24,6 @@ export interface SlackLiveTaskCardContent { * the card output. Always the latest one, never accumulated. */ message?: string; taskUrl?: string; - sessionMode?: boolean; } /** @@ -62,9 +53,7 @@ export function buildSlackLiveTaskCardBlocks( text: [ content.title, message, - content.taskUrl - ? `<${content.taskUrl}|${content.sessionMode ? 'Open in Roomote' : 'Open the task'}>` - : undefined, + content.taskUrl ? `<${content.taskUrl}|Open in Roomote>` : undefined, ] .filter((line): line is string => Boolean(line)) .join('\n'), @@ -82,7 +71,7 @@ export function buildSlackLiveTaskCardBlocks( { type: 'url', url: content.taskUrl, - text: content.sessionMode ? 'Open in Roomote' : 'View task', + text: 'Open in Roomote', }, ], } diff --git a/packages/slack/src/live-task-stream.ts b/packages/slack/src/live-task-stream.ts index f4bfdac2b..20aaf9005 100644 --- a/packages/slack/src/live-task-stream.ts +++ b/packages/slack/src/live-task-stream.ts @@ -15,7 +15,6 @@ 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 a9312e3b4..3d49866a8 100644 --- a/packages/slack/src/settle-live-task-card.ts +++ b/packages/slack/src/settle-live-task-card.ts @@ -3,7 +3,6 @@ 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 { @@ -75,7 +74,6 @@ export async function renderSlackLiveTaskCard(input: { status: input.status, ...(input.message ? { message: input.message } : {}), ...(data.taskUrl ? { taskUrl: data.taskUrl } : {}), - sessionMode: data.sessionMode === true, }), }); @@ -107,8 +105,8 @@ export async function settleSlackLiveTaskCardForRun(input: { status: 'error', message: input.status === RunStatus.Canceled - ? await dataSessionMessages(input.taskId, 'canceled') - : await dataSessionMessages(input.taskId, 'failed'), + ? SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.canceled + : SLACK_SESSION_LIVE_TASK_CARD_MESSAGES.failed, taskTitle: input.taskTitle, }); } catch (error) { @@ -117,13 +115,3 @@ 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/packages/types/src/index.ts b/packages/types/src/index.ts index 2bc249120..ea86228e2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,6 +11,7 @@ export * from './automation-destination-fields'; export * from './cloud-agents'; export * from './pr-review-action'; export * from './task-runs'; +export * from './sessions'; export * from './fast-agent'; export * from './chatgpt-subscription'; export * from './github-copilot-subscription'; diff --git a/packages/types/src/sessions.ts b/packages/types/src/sessions.ts new file mode 100644 index 000000000..78064cf8b --- /dev/null +++ b/packages/types/src/sessions.ts @@ -0,0 +1,15 @@ +/** Unified Session lifecycle statuses, mirrored by the sessions table's + * cached_status check constraint. Derive UI option lists, board columns, and + * validation from this array rather than re-declaring the literals. */ +export const SESSION_STATUSES = [ + 'active', + 'needs_input', + 'blocked', + 'ready', +] as const; + +export type SessionStatus = (typeof SESSION_STATUSES)[number]; + +export function getSessionStatusLabel(status: SessionStatus | string): string { + return status.replace('_', ' '); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5fe8716..864f12396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,9 +283,6 @@ 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 @@ -1139,9 +1136,6 @@ importers: '@roomote/env': specifier: workspace:^ version: link:../env - '@roomote/feature-flags': - specifier: workspace:^ - version: link:../feature-flags '@roomote/gitea': specifier: workspace:^ version: link:../gitea @@ -1651,9 +1645,6 @@ importers: '@roomote/env': specifier: workspace:^ version: link:../env - '@roomote/feature-flags': - specifier: workspace:^ - version: link:../feature-flags '@roomote/redis': specifier: workspace:^ version: link:../redis From fa086165b211ee551790609a3d73a03fab7b1a00 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:56:41 -0400 Subject: [PATCH 34/39] Polish the Sessions list: pluralized executions, pretty repository names, no zero-cost/zero-execution chips, drop the Environment ID input --- .../(authenticated)/sessions/SessionCard.tsx | 19 +++++++++++++++---- .../sessions/SessionsFilters.tsx | 14 -------------- .../src/app/(authenticated)/sessions/page.tsx | 1 - .../sessions/[sessionId]/SessionTaskCards.tsx | 16 +++++++++++----- .../sessions/[sessionId]/SessionWorkspace.tsx | 10 ++++++++-- apps/web/src/lib/formatters.ts | 10 ++++++++++ 6 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx index 348c78eac..38641bbb1 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx @@ -1,7 +1,11 @@ import Link from 'next/link'; import { formatDistanceToNow } from 'date-fns'; -import { formatInferenceCost, getUserDisplayName } from '@/lib'; +import { + formatInferenceCost, + formatRepositoryName, + getUserDisplayName, +} from '@/lib'; import { Avatar } from '@/components/system'; import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; import { getSessionSurfaceLabel } from '@/components/sessions/session-surfaces'; @@ -67,12 +71,19 @@ export function SessionCard({ session }: { session: SessionCardData }) {
- {session.executionCount} executions {getSessionSurfaceLabel(session.sourceSurface)} {primaryTask?.repositoryName ? ( - {primaryTask.repositoryName} + {formatRepositoryName(primaryTask.repositoryName)} + ) : null} + {session.executionCount > 0 ? ( + + {session.executionCount} execution + {session.executionCount === 1 ? '' : 's'} + + ) : null} + {session.inferenceCostMicroUsd > 0 ? ( + ${formatInferenceCost(session.inferenceCostMicroUsd)} ) : null} - ${formatInferenceCost(session.inferenceCostMicroUsd)}
diff --git a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx index a6649ab96..9e302fd39 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx @@ -29,7 +29,6 @@ export function SessionsFilters({ pullRequest = null, model = null, source = 'all', - environment = '', }: { userId: string | null; timePeriod: TimePeriodFilter; @@ -41,7 +40,6 @@ export function SessionsFilters({ pullRequest?: string | null; model?: string | null; source?: string; - environment?: string; }) { const router = useRouter(); const pathname = usePathname(); @@ -128,11 +126,6 @@ export function SessionsFilters({ 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'); }); }} > @@ -143,13 +136,6 @@ export function SessionsFilters({ placeholder="Search sessions" className="h-8" /> - diff --git a/apps/web/src/app/(authenticated)/sessions/page.tsx b/apps/web/src/app/(authenticated)/sessions/page.tsx index d6f989a30..b8241974e 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.tsx @@ -81,7 +81,6 @@ export default async function SessionsPage({ view={view} query={q ?? ''} repository={params.repository ?? null} - environment={params.environment ?? ''} pullRequest={params.pullRequest ?? null} source={params.source ?? 'all'} model={params.model ?? null} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx index f30381348..edd3fdede 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { formatInferenceCost } from '@/lib'; +import { formatInferenceCost, formatRepositoryName } from '@/lib'; import { Badge, Button, @@ -96,7 +96,11 @@ export function SessionTaskCards({ -

{task.repositoryName ?? task.workflow}

+

+ {task.repositoryName + ? formatRepositoryName(task.repositoryName) + : task.workflow} +

{task.latestRun?.error ? (

{task.latestRun.error} @@ -105,9 +109,11 @@ export function SessionTaskCards({ {task.latestOutput ? (

{task.latestOutput}

) : null} -

- ${formatInferenceCost(task.inferenceCostMicroUsd)} inference -

+ {task.inferenceCostMicroUsd > 0 ? ( +

+ ${formatInferenceCost(task.inferenceCostMicroUsd)} inference +

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

Execution details require task access.

) : null} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 645554bbf..8e1f3edf2 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -12,7 +12,11 @@ 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 { + formatInferenceCost, + formatRepositoryName, + getUserDisplayName, +} from '@/lib'; import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; import { getSessionSurfaceBrandIcon, @@ -126,7 +130,9 @@ function SessionTaskPanel({

{task.title}

{task.state}

{task.repositoryName ? ( -

{task.repositoryName}

+

+ {formatRepositoryName(task.repositoryName)} +

) : null} {task.canAccessDetails === false ? ( diff --git a/apps/web/src/lib/formatters.ts b/apps/web/src/lib/formatters.ts index 92b5278e4..bb92891f1 100644 --- a/apps/web/src/lib/formatters.ts +++ b/apps/web/src/lib/formatters.ts @@ -1,6 +1,8 @@ import { format, formatDistanceToNowStrict } from 'date-fns'; import { enUS } from 'date-fns/locale'; +import { ALL_REPOSITORIES } from '@roomote/types'; + /** * Formats a number to be more readable (e.g., 2300 → 2.3K, 6700000 → 6.7M) * @param value The number to format @@ -177,3 +179,11 @@ export function formatTokens(tokens: number): string { return `${(tokens / 1000000000).toFixed(1)}B`; } + +/** + * Repository names may carry the ALL_REPOSITORIES sentinel; render its pretty + * label instead of the raw `__all_repositories__` value. + */ +export function formatRepositoryName(name: string): string { + return name === ALL_REPOSITORIES ? 'All Repositories' : name; +} From 8e492df05b281c3a50c91de080f63c12a18653f4 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:27:37 +0100 Subject: [PATCH 35/39] [Improve] Make tool activity easier to understand (#1773) * feat: standardize tool presentation * fix: hide sandbox paths in tool titles * feat: add tool call story inventories * feat: add specific tool call icons * feat: show MCP integration icons in tool calls --------- Co-authored-by: Roomote Co-authored-by: Bruno Bergher --- .../FastSessionTranscript.client.test.tsx | 36 +- .../[sessionId]/FastSessionTranscript.tsx | 7 +- .../messages/acp/AcpGroupedToolMessage.tsx | 89 ++-- .../[taskId]/messages/acp/AcpMessageItem.tsx | 11 +- .../[taskId]/messages/acp/AcpToolDetails.tsx | 109 +++-- .../[taskId]/messages/acp/AcpToolMessage.tsx | 87 ++-- .../AcpGroupedToolMessage.client.test.tsx | 9 +- .../__tests__/AcpToolDetails.client.test.tsx | 98 +++-- .../__tests__/AcpToolMessage.client.test.tsx | 31 +- .../tool-call-grouping.client.test.ts | 53 ++- .../tool-presentation.client.test.ts | 198 +++++++++ .../[taskId]/messages/acp/activity-groups.ts | 75 +--- .../[taskId]/messages/acp/render-blocks.ts | 233 ++-------- .../messages/acp/tool-detail-visibility.ts | 20 +- .../task/[taskId]/messages/acp/tool-icons.ts | 67 +++ .../messages/acp/tool-presentation-policy.ts | 140 ++++++ .../messages/acp/tool-presentation.ts | 348 +++++++++++++++ .../ai-elements/message.stories.tsx | 407 ++++++++---------- .../ai-elements/tool.client.test.tsx | 73 +++- apps/web/src/components/ai-elements/tool.tsx | 46 +- .../components/system/custom/icons/index.ts | 1 + .../system/custom/icons/roomote-r.tsx | 28 ++ .../src/components/system/primitives/icons.ts | 2 +- .../__tests__/fast-agent-service.test.ts | 18 + .../__tests__/fast-agent-tool-policy.test.ts | 29 ++ .../server/fast-agent/fast-agent-service.ts | 17 +- .../fast-agent/fast-agent-tool-policy.ts | 27 +- packages/types/src/acp.ts | 21 + packages/types/src/fast-agent-tool-catalog.ts | 73 ++++ packages/types/src/index.ts | 1 + 30 files changed, 1610 insertions(+), 744 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts create mode 100644 apps/web/src/components/system/custom/icons/roomote-r.tsx create mode 100644 packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-policy.test.ts create mode 100644 packages/types/src/fast-agent-tool-catalog.ts 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 31d874a2f..f2e1e1624 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,13 +9,17 @@ import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; import { FastSessionTranscript } from './FastSessionTranscript'; -const { replyMutate, preparePromptAttachments, openTaskPanel } = vi.hoisted( - () => ({ +const { replyMutate, preparePromptAttachments, openTaskPanel, narrationState } = + vi.hoisted(() => ({ replyMutate: vi.fn(), preparePromptAttachments: vi.fn(), openTaskPanel: vi.fn(), - }), -); + narrationState: { enabled: false }, + })); + +vi.mock('@/hooks/useNarrationMode', () => ({ + useNarrationMode: () => ({ enabled: narrationState.enabled }), +})); vi.mock('@/trpc/client', () => ({ useTRPCClient: () => ({ @@ -92,6 +96,7 @@ beforeEach(() => { preparePromptAttachments.mockImplementation(({ text }: { text: string }) => Promise.resolve({ text }), ); + narrationState.enabled = false; openTaskPanel.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); @@ -229,7 +234,9 @@ describe('FastSessionTranscript', () => { />, ); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Starting')).toBeInTheDocument(); + expect(screen.getByText('Coding Task')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); expect(FakeEventSource.instances).toHaveLength(1); expect(FakeEventSource.instances[0]!.url).toBe( '/api/sessions/session-1/stream', @@ -241,7 +248,8 @@ describe('FastSessionTranscript', () => { }); }); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Started')).toBeInTheDocument(); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); it('renders trusted Fast show_widget results with the shared sandboxed preview', () => { @@ -301,7 +309,8 @@ describe('FastSessionTranscript', () => { ); }); - it('opens a launched child task in the session side panel', () => { + it('keeps a launched child task visible in narration mode and opens its panel', () => { + narrationState.enabled = true; render( { payload: { toolCallId: 'turn-1:tool:0', title: 'launch_task', - kind: 'tool', + kind: 'task', status: 'completed', isExecute: false, isMcp: false, @@ -420,14 +429,9 @@ describe('FastSessionTranscript', () => { />, ); - const activityToggle = screen.getByRole('button', { - name: /Worked for/, - }); - expect(screen.queryByText('launch_task')).not.toBeInTheDocument(); - - fireEvent.click(activityToggle); - - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect( + screen.getByRole('button', { name: /Started Coding Task Completed/ }), + ).toBeInTheDocument(); expect(screen.getByText('I started the checkout fix.')).toBeInTheDocument(); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 27e4123eb..401d66bf8 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -32,6 +32,7 @@ import { } from './SessionPromptInput'; import { preparePromptAttachments } from '@/lib/prompt-attachments'; import { useOpenSessionTaskPanel } from './session-task-panel-context'; +import { useNarrationMode } from '@/hooks/useNarrationMode'; import { AcpTranscriptBlockList, @@ -87,6 +88,8 @@ export function FastSessionTranscript({ }) { const trpcClient = useTRPCClient(); const openTaskPanel = useOpenSessionTaskPanel(); + const { enabled: narrationModeEnabled } = useNarrationMode(); + const displayMode = narrationModeEnabled ? 'narration' : 'default'; const [serverMessages, setServerMessages] = useState< Map >( @@ -182,7 +185,7 @@ export function FastSessionTranscript({ const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({ messages: uiMessages, artifacts: [], - displayMode: 'default', + displayMode, initialPrompt: null, shouldHideFirstMessage: false, showInternalMessages: false, @@ -268,7 +271,7 @@ export function FastSessionTranscript({ ); return ( - + item.msg.partial === true || item.msg.data.status === 'in_progress', ); - const showExpandedDetails = group.items.length > 0; + const showExpandedDetails = group.items.some( + (item) => + resolveToolPresentationPolicy(item.msg, { + showInternalMessages: showSubagentPayload, + }).detailMode === 'expandable', + ); const toolState = hasFailed ? 'output-error' @@ -61,8 +55,12 @@ export function AcpGroupedToolMessage({ ? 'input-available' : 'output-available'; + const firstPresentation = resolveToolPresentation( + group.items[0]!.msg.data, + group.items[0]!.msg.partial, + ); const ToolIcon = groupedToolIcon({ - displayKind: group.displayKind, + presentation: firstPresentation, hasFailed, hasRunning, }); @@ -92,15 +90,20 @@ export function AcpGroupedToolMessage({ const sectionTitle = sanitizeSandboxPathString( item.objectLabel, ); - const showItemDetails = !hidesExpandedToolResult(item.msg, { - showSubagentPayload, - }); + const showItemDetails = + resolveToolPresentationPolicy(item.msg, { + showInternalMessages: showSubagentPayload, + }).detailMode === 'expandable'; + const presentation = resolveToolPresentation( + item.msg.data, + item.msg.partial, + ); return (
{sectionTitle} @@ -124,48 +127,26 @@ export function AcpGroupedToolMessage({ } function groupedToolIcon(params: { - displayKind: GroupedToolDisplayKind; + presentation: ReturnType; hasRunning: boolean; hasFailed: boolean; }): LucideIcon { - if (params.hasRunning) return Loader2; if (params.hasFailed) return AlertCircle; - return groupedDisplayKindIcon(params.displayKind); + if (params.hasRunning) return Loader2; + return params.presentation.integrationIcon + ? mcpIntegrationIconFor(params.presentation.integrationIcon) + : toolIconForKey(params.presentation.iconKey); } function GroupedToolItemIcon({ - displayKind, + presentation, className, }: { - displayKind: GroupedToolDisplayKind; + presentation: ReturnType; className?: string; }) { - const Icon = groupedDisplayKindIcon(displayKind); + const Icon = presentation.integrationIcon + ? mcpIntegrationIconFor(presentation.integrationIcon) + : toolIconForKey(presentation.iconKey); return ; } - -function groupedDisplayKindIcon( - displayKind: GroupedToolDisplayKind, -): LucideIcon { - if (displayKind === 'search') { - return Search; - } - - if (displayKind === 'list') { - return FolderIcon; - } - - if (displayKind === 'read') { - return FileIcon; - } - - if (displayKind === 'execute') { - return Terminal; - } - - if (displayKind === 'edit') { - return SquarePen; - } - - return Wrench; -} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx index d2f0ebc86..304d023bb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx @@ -10,6 +10,7 @@ import { AcpToolMessage } from './AcpToolMessage'; import { AcpUnknownMessage } from './AcpUnknownMessage'; import { DelegatedTaskCard } from './DelegatedTaskCard'; import { getDelegatedTaskDetails } from './delegated-task'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; interface AcpMessageItemProps { msg: AcpUiMessage; @@ -36,8 +37,16 @@ function AcpMessageItemBase({ case 'tool_call': case 'tool_result': { const delegatedTask = getDelegatedTaskDetails(msg); + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: Boolean(onOpenDelegatedTask), + showInternalMessages: showSubagentPayload, + }); - if (delegatedTask && onOpenDelegatedTask) { + if ( + policy.renderAs === 'delegated-task-card' && + delegatedTask && + onOpenDelegatedTask + ) { return ( ); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx index a106eba52..fa4f9c4ea 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx @@ -42,10 +42,6 @@ export function AcpToolDetails({ const sanitizedToolData = sanitizeSandboxPathsForDisplay(msg.data); const visibleToolInput = getVisibleToolInput(msg.data); - const sanitizedText = msg.text - ? sanitizeSandboxPathString(msg.text) - : msg.text; - const formattedText = formatToolDetails(sanitizedText, visibleToolInput); const isSubagent = isSubagentToolPayload(msg.data); const subagentPrompt = getSubagentPrompt(msg); const subagentLastMessage = getSubagentLastMessage(msg); @@ -91,16 +87,45 @@ export function AcpToolDetails({ ); } - return formattedText ? ( - - ) : ( + const rawResult = + msg.kind === 'tool_result' + ? msg.data.output || msg.text + : visibleToolInput + ? undefined + : msg.text; + const sanitizedResult = rawResult + ? sanitizeSandboxPathString(rawResult) + : undefined; + const formattedInput = formatStructuredValue(visibleToolInput); + const formattedResult = formatToolResult( + sanitizedResult, + Boolean(formattedInput), + ); + + if (formattedInput || formattedResult) { + return ( +
+ {formattedInput ? ( + + ) : null} + {formattedResult ? ( + + ) : null} +
+ ); + } + + return ( +
{label}
+ +
+ ); +} + +function formatStructuredValue( + value: Record | null, +): { code: string } | undefined { + if (!value || Object.keys(value).length === 0) return undefined; + + return { + code: YAML.stringify(value, { indent: 2, lineWidth: 0 }).trimEnd(), + }; +} + +function formatToolResult( text: string | undefined, - visibleToolInput: Record | null, + hasVisibleInput: boolean, ): { code: string; isStructured: boolean } | undefined { if (!text) return undefined; @@ -123,19 +184,17 @@ function formatToolDetails( return { code: text, isStructured: false }; } - const details = - !Array.isArray(result) && - visibleToolInput && - Object.keys(visibleToolInput).length > 0 - ? { ...(result as Record), ...visibleToolInput } - : result; - return { - code: YAML.stringify(details, { indent: 2, lineWidth: 0 }).trimEnd(), + code: YAML.stringify(result, { indent: 2, lineWidth: 0 }).trimEnd(), isStructured: true, }; } catch { - return { code: text, isStructured: false }; + return hasVisibleInput + ? { + code: YAML.stringify(text, { lineWidth: 0 }).trimEnd(), + isStructured: true, + } + : { code: text, isStructured: false }; } } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolMessage.tsx index 6e0d1c1c9..99a26d463 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolMessage.tsx @@ -7,17 +7,7 @@ import { sanitizeSandboxPathString, } from '@/lib'; -import { - type LucideIcon, - AlertCircle, - Bot, - Eye, - Loader2, - SquarePen, - Terminal, - Search, - Wrench, -} from '@/components/system'; +import { AlertCircle, Loader2 } from '@/components/system'; import { Message, MessageContent, @@ -32,11 +22,13 @@ import { messageAnchorId } from '../message-anchor'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; import { AcpToolDetails } from './AcpToolDetails'; import { isSubagentToolPayload } from './subagent-tool'; -import { hidesExpandedToolResult } from './tool-detail-visibility'; import { ShowWidgetPreview } from './ShowWidgetPreview'; import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; import { VisualProofToolPreview } from './VisualProofToolPreview'; import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; +import { resolveToolPresentation } from './tool-presentation'; +import { mcpIntegrationIconFor, toolIconForKey } from './tool-icons'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; interface AcpToolMessageProps { msg: AcpToolCallUiMessage | AcpToolResultUiMessage; @@ -51,7 +43,6 @@ export function AcpToolMessage({ }: AcpToolMessageProps) { const artifactLink = useArtifactLink(); const anchorId = messageAnchorId(msg.ts); - const kind = msg.data.kind; const title = sanitizeSandboxPathString(msg.data.title ?? 'Tool use'); const sanitizedToolData = sanitizeSandboxPathsForDisplay(msg.data); @@ -65,10 +56,15 @@ export function AcpToolMessage({ ? 'input-available' : 'output-available'; - const ToolIcon = toolKindIcon({ kind, isRunning, isFailed }); + const presentation = resolveToolPresentation(msg.data, msg.partial); + const ToolIcon = isFailed + ? AlertCircle + : isRunning + ? Loader2 + : presentation.integrationIcon + ? mcpIntegrationIconFor(presentation.integrationIcon) + : toolIconForKey(presentation.iconKey); - const isMcp = msg.data.isMcp; - const isMcpLabelPresent = Boolean(msg.data.toolName || msg.data.serverName); const visualProofMedia = resolveVisualProofMediaForToolMessage( msg, artifactLink?.artifacts, @@ -77,13 +73,15 @@ export function AcpToolMessage({ const showWidget = resolveShowWidgetForToolMessage(msg); const showWidgetPreview = showWidget !== null; const isSubagentRow = isSubagentToolPayload(msg.data); + const policy = resolveToolPresentationPolicy(msg, { + artifacts: artifactLink?.artifacts, + showInternalMessages: showSubagentPayload, + }); // Direct manage_artifacts / show_widget rows collapse to just the preview; // subagent rows keep their collapsible prompt/result details alongside it. const showExpandedDetails = (isSubagentRow || (!showVisualProofPreview && !showWidgetPreview)) && - !hidesExpandedToolResult(msg, { - showSubagentPayload, - }); + policy.detailMode === 'expandable'; const showNestedActivity = Boolean(children); const showCollapsibleContent = showExpandedDetails || showNestedActivity; @@ -92,28 +90,24 @@ export function AcpToolMessage({ ); // Running: agent name · current action · elapsed. Settled: agent name · // spawn title · total elapsed + call count (the receipt). - const showSubagentRow = kind === 'subagent' && subagentActivity !== null; + const showSubagentRow = presentation.category === 'subagent'; const action = showSubagentRow - ? (subagentActivity.agentType ?? title) - : isMcp && isMcpLabelPresent - ? isRunning - ? 'Using' - : 'Used' - : title; + ? (subagentActivity?.agentType ?? msg.data.agentType ?? title) + : presentation.verb; const object = showSubagentRow ? isRunning - ? sanitizeSandboxPathString(subagentActivity.lastAction ?? 'starting…') + ? sanitizeSandboxPathString(subagentActivity?.lastAction ?? 'starting…') : title - : isMcp && isMcpLabelPresent - ? formatToolPart(msg.data.toolName ?? msg.data.serverName ?? '') - : undefined; + : presentation.object; const suffix = showSubagentRow - ? formatSubagentElapsed(subagentActivity) - : msg.data.isMcp && msg.data.toolName && msg.data.serverName - ? formatToolPart(msg.data.serverName) + ? subagentActivity + ? formatSubagentElapsed(subagentActivity) + : undefined + : presentation.identity.providerKind === 'mcp' + ? presentation.providerLabel : undefined; const suffixPrefix = showSubagentRow ? '·' : 'from'; @@ -199,30 +193,3 @@ function formatSubagentElapsed(activity: SubagentActivity): string | undefined { ? `${elapsed} · ${count} ${count === 1 ? 'call' : 'calls'}` : elapsed; } - -/** Format a raw tool/server identifier into a human-readable label. */ -function formatToolPart(str: string): string { - if (str.toLowerCase() === 'gbrain') return 'Hippocampus'; - - return str - .replace(/[.]/g, ' ') - .replace(/[-_]/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (c) => c.toUpperCase()) - .trim(); -} - -function toolKindIcon(params: { - kind: string | null; - isRunning: boolean; - isFailed: boolean; -}): LucideIcon { - if (params.isRunning) return Loader2; - if (params.isFailed) return AlertCircle; - if (params.kind === 'subagent') return Bot; - if (params.kind === 'read') return Eye; - if (params.kind === 'execute') return Terminal; - if (params.kind === 'search') return Search; - if (params.kind === 'edit') return SquarePen; - return Wrench; -} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx index 6985d2161..98869452d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx @@ -120,15 +120,12 @@ describe('AcpGroupedToolMessage', () => { codeBlockSpy.mockClear(); }); - it('renders grouped header and per-file sections', () => { + it('keeps grouped read rows compact when no item has expandable details', () => { render(); expect(screen.getByText('Exploring 2 files')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt')).toBeInTheDocument(); - expect(screen.getByText('file_b.txt')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt').className).toContain('truncate'); - expect(screen.getByText('file_b.txt').className).toContain('truncate'); - + expect(screen.queryByText('file_a.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('file_b.txt')).not.toBeInTheDocument(); expect(codeBlockSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx index 78779d639..a2e9d2bd7 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx @@ -281,7 +281,7 @@ describe('AcpToolDetails', () => { }); it.each(['search', 'query'])( - 'adds the sanitized Hippocampus %s query to the existing result YAML', + 'renders the sanitized Memory %s input before the result YAML', (toolName) => { const result = { matches: [{ title: 'Existing result', score: 0.98 }], @@ -308,20 +308,14 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'matches:', - ' - title: Existing result', - ' score: 0.98', - 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', + ['matches:', ' - title: Existing result', ' score: 0.98'].join( + '\n', + ), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }, ); @@ -349,19 +343,12 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'delivered: true', - 'taskId: task-1', - 'message: Review RooCodeInc/Roomote and use password=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'message: Review RooCodeInc/Roomote and use password=[redacted]', + ['delivered: true', 'taskId: task-1'].join('\n'), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }); @@ -389,6 +376,61 @@ describe('AcpToolDetails', () => { expect(toolInputSpy).not.toHaveBeenCalled(); }); + it('keeps colliding input and result fields separate', () => { + render( + ), + text: JSON.stringify({ query: 'result value', matches: 2 }), + }} + />, + ); + + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: requested value', + ['query: result value', 'matches: 2'].join('\n'), + ]); + }); + + it('keeps input visible when a truncated result is no longer valid JSON', () => { + render( + ), + text: '{"matches":[\n... output truncated ...\n]}', + }} + />, + ); + + expect(codeBlockSpy.mock.calls[0]?.[0].code).toBe('query: large result'); + expect(codeBlockSpy.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + language: 'yaml', + code: expect.stringContaining('output truncated'), + }), + ); + }); + it('hides expanded details for Roomote Slack lifecycle tools', () => { const { container } = render( { expect(toolDetailsSpy).not.toHaveBeenCalled(); }); - it('renders the gbrain MCP server as Hippocampus', () => { + it('renders the gbrain MCP server as Memory', () => { render( { expect.objectContaining({ action: 'Used', object: 'Query', - suffix: 'Hippocampus', + suffix: 'Memory', + }), + ); + }); + + it('uses the known MCP integration’s brand icon', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: mcpIntegrationIconFor('sentry'), + suffix: 'Sentry', }), ); }); @@ -387,7 +410,7 @@ describe('AcpToolMessage', () => { expect(toolHeaderSpy).toHaveBeenCalledWith( expect.objectContaining({ - icon: Eye, + icon: FileIcon, collapsible: false, }), ); 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 0ea0c5827..fe9a68221 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 @@ -820,7 +820,7 @@ describe('buildAcpRenderBlocks', () => { kind: 'tool_group', action: 'Used', objectSummary: '2 get issue calls', - displayKind: 'tool', + displayKind: 'generic', }); }); @@ -2037,4 +2037,55 @@ describe('buildAcpRenderBlocks', () => { expect(entries).toHaveLength(2); expect(entries.every((entry) => entry.kind === 'message')).toBe(true); }); + + it('keeps adjacent widget previews standalone', () => { + const widget = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'show_widget', + kind: 'mcp', + toolName: 'show_widget', + text: JSON.stringify({ + success: true, + shown: true, + html: `

${id}

`, + height: 240, + }), + }); + + const entries = buildAcpRenderBlocks([ + widget('widget-1', 1), + widget('widget-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent visual-proof uploads standalone', () => { + const proof = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'manage_artifacts', + kind: 'mcp', + toolName: 'manage_artifacts', + text: JSON.stringify({ + success: true, + artifactId: id, + artifactType: 'visual-proof', + viewUrl: `https://example.com/task/task-1/artifacts/${id}.png`, + rawUrl: `https://example.com/${id}.png`, + }), + }); + + const entries = buildAcpRenderBlocks([ + proof('proof-1', 1), + proof('proof-2', 2), + ]); + + 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/__tests__/tool-presentation.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts new file mode 100644 index 000000000..7cc739ba9 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts @@ -0,0 +1,198 @@ +import type { AcpToolResultPayload } from '@roomote/types'; + +import { resolveToolPresentation } from '../tool-presentation'; +import { resolveToolPresentationPolicy } from '../tool-presentation-policy'; +import type { AcpToolResultUiMessage } from '../types'; + +function toolData( + overrides: Partial = {}, +): AcpToolResultPayload { + return { + toolCallId: 'call-1', + kind: 'tool', + title: 'custom_tool', + isExecute: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: null, + exitCode: null, + output: '{}', + status: 'completed', + ...overrides, + }; +} + +function toolMessage( + overrides: Partial = {}, +): AcpToolResultUiMessage { + const data = toolData(overrides); + return { + id: 'message-1', + ts: 1, + role: 'tool', + partial: false, + sessionId: 'session-1', + updateType: 'roomote_runtime.tool_result', + kind: 'tool_result', + text: data.output, + data, + }; +} + +describe('tool presentation resolver', () => { + it.each([ + [{ kind: 'execute', isExecute: true }, 'execute', 'terminal'], + [{ kind: 'read' }, 'read', 'file'], + [{ toolName: 'spill_grep' }, 'search', 'search'], + [{ toolName: 'list_skills' }, 'list', 'folder'], + [{ toolName: 'launch_task' }, 'task', 'task'], + [{ toolName: 'save_memory' }, 'memory', 'memory'], + [{ toolName: 'show_widget' }, 'widget', 'widget'], + ] as const)('classifies %o as %s', (overrides, category, iconKey) => { + expect(resolveToolPresentation(toolData(overrides))).toMatchObject({ + category, + iconKey, + }); + }); + + it.each([ + ['manage_custom_automations', 'task'], + ['get_about_me', 'roomote'], + ['describe_video', 'video'], + ['manage_goal', 'target'], + ['manage_tasks', 'list-checks'], + ['manage_source_control', 'pull-request'], + ['manage_environments', 'environment'], + ['save_task_memory', 'memory'], + ['request_environment_variables', 'terminal'], + ['report_platform_issue', 'alert'], + ['submit_automation_work_items', 'task'], + ['list_chat_channels', 'messages'], + ['get_chat_channel_messages', 'messages'], + ['get_chat_message_context', 'messages'], + ] as const)('uses the %s icon for %s', (toolName, iconKey) => { + expect(resolveToolPresentation(toolData({ toolName }))).toMatchObject({ + iconKey, + }); + }); + + it('uses Memory as the provider label without changing canonical identity', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'gbrain', + mcpToolName: 'query', + serverName: 'gbrain', + toolName: 'query', + }), + ), + ).toMatchObject({ + category: 'memory', + providerLabel: 'Memory', + identity: { serverName: 'gbrain', toolName: 'query' }, + }); + }); + + it('uses a known MCP integration’s catalog label and icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'search_issues', + serverName: 'sentry', + toolName: 'search_issues', + }), + ), + ).toMatchObject({ + integrationIcon: 'sentry', + providerLabel: 'Sentry', + }); + }); + + it('keeps explicit tool icons ahead of an MCP integration icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'manage_goal', + serverName: 'sentry', + toolName: 'manage_goal', + }), + ), + ).toMatchObject({ iconKey: 'target', integrationIcon: undefined }); + }); + + it('uses meaningful receipt language for consequential task actions', () => { + expect( + resolveToolPresentation(toolData({ toolName: 'launch_task' })), + ).toMatchObject({ verb: 'Started', object: 'Coding Task' }); + expect( + resolveToolPresentation( + toolData({ toolName: 'launch_task', status: 'failed' }), + ), + ).toMatchObject({ verb: 'Failed to Start', object: 'Coding Task' }); + }); + + it('sanitizes native fallback titles without using them for identity', () => { + expect( + resolveToolPresentation( + toolData({ + title: 'Read /sandbox/repos/RooCodeInc/Roomote/apps/web/package.json', + toolName: null, + }), + ), + ).toMatchObject({ + displayName: 'Read RooCodeInc/Roomote/apps/web/package.json', + object: 'Read RooCodeInc/Roomote/apps/web/package.json', + identity: { toolName: null }, + groupKey: 'kind:tool', + }); + }); +}); + +describe('tool presentation policy', () => { + it('keeps consequential receipts outside collapsed activity', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'save_memory', kind: 'memory' }), + ).activityMode, + ).toBe('keep-visible'); + }); + + it('keeps delegated task cards visible in narration mode only on card-enabled surfaces', () => { + const message = toolMessage({ + toolName: 'launch_task', + kind: 'task', + output: JSON.stringify({ success: true, taskId: 'task-1' }), + }); + + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: true, + displayMode: 'narration', + }), + ).toMatchObject({ + renderAs: 'delegated-task-card', + rowVisibility: 'visible', + activityMode: 'keep-visible', + }); + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: false, + }).renderAs, + ).toBe('row'); + }); + + it('keeps ordinary exploration hidden in narration mode', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'read_file', kind: 'read' }), + { displayMode: 'narration' }, + ).rowVisibility, + ).toBe('hidden'); + }); +}); 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 f1391c2f6..d3f69f225 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 @@ -8,9 +8,7 @@ import type { AcpUiMessage, } from './types'; 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'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; const COLLAPSIBLE_ACP_MESSAGE_KINDS = [ 'reasoning', @@ -22,10 +20,6 @@ const COLLAPSIBLE_ACP_MESSAGE_KIND_SET = new Set( COLLAPSIBLE_ACP_MESSAGE_KINDS, ); -const MANAGE_ARTIFACTS_TOOL_NAME = 'manage_artifacts'; -const SHOW_WIDGET_TOOL_NAME = 'show_widget'; -const ROOMOTE_MCP_SERVER_NAME = 'roomote'; - export interface AcpActivityGroupRenderBlock { kind: 'activity_group'; id: string; @@ -94,48 +88,6 @@ function isActivityBoundaryBlock(block: AcpRenderBlock): boolean { return isTextBoundaryBlock(block) || isProgressBoundaryBlock(block); } -function getToolName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.toolName ?? msg.data.mcpToolName; - const normalized = rawName?.trim().toLowerCase(); - - return normalized && normalized.length > 0 ? normalized : null; -} - -function getServerName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.serverName ?? msg.data.mcpServerName; - const normalized = rawName?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function isArtifactToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - artifacts: readonly TaskArtifact[] | null | undefined, -): boolean { - const toolName = getToolName(msg); - const serverName = getServerName(msg); - - if (toolName === MANAGE_ARTIFACTS_TOOL_NAME) { - return true; - } - - if ( - toolName === SHOW_WIDGET_TOOL_NAME && - serverName === ROOMOTE_MCP_SERVER_NAME - ) { - return true; - } - - if (resolveShowWidgetForToolMessage(msg) !== null) { - return true; - } - - return resolveVisualProofMediaForToolMessage(msg, artifacts).length > 0; -} - function isLivePartialBlock(block: AcpRenderBlock): boolean { if (block.kind === 'tool_group') { return block.items.some( @@ -163,8 +115,12 @@ export function isActivityCollapsibleBlock( } if (block.kind === 'tool_group') { - return !block.items.some((item) => - isArtifactToolMessage(item.msg, artifacts), + return !block.items.some( + (item) => + resolveToolPresentationPolicy(item.msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'keep-visible', ); } @@ -178,16 +134,13 @@ export function isActivityCollapsibleBlock( return false; } - if (isToolMessage(msg) && isArtifactToolMessage(msg, artifacts)) { - return false; - } - - if ( - keepDelegatedTasksVisible && - isToolMessage(msg) && - getDelegatedTaskDetails(msg) - ) { - return false; + if (isToolMessage(msg)) { + return ( + resolveToolPresentationPolicy(msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'collapsible' + ); } return true; 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 b82defe01..9c8289e11 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 @@ -4,48 +4,24 @@ import { normalizeTranscriptUserText, } from '@roomote/types'; -import { - isInternalDebugToolCallMessage, - shouldHideAcpMessage, -} from '../../message-visibility'; +import { shouldHideAcpMessage } from '../../message-visibility'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage, AcpUiMessage, } from './types'; +import { isSubagentToolMessage, isSubagentToolPayload } from './subagent-tool'; import { - isSubagentSpawnRowMessage, - isSubagentToolMessage, - isSubagentToolPayload, -} from './subagent-tool'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; -import { getDelegatedTaskDetails } from './delegated-task'; - -export type ExplorationStepKind = 'list' | 'read' | 'search'; - -export type GroupedToolDisplayKind = - | ExplorationStepKind - | 'execute' - | 'edit' - | 'tool'; - -const EXPLORATION_TOOL_NAMES: Record> = { - search: new Set(['search', 'search_file', 'search_files']), - list: new Set(['glob', 'list', 'list_dir', 'list_directory', 'list_files']), - read: new Set(['read', 'read_file']), -}; + resolveToolPresentation, + summarizeToolGroup, + type ToolPresentationCategory, +} from './tool-presentation'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; -const STEP_KIND_ORDER: ExplorationStepKind[] = ['search', 'list', 'read']; +type ExplorationStepKind = 'list' | 'read' | 'search'; -const STEP_KIND_LABELS: Record< - ExplorationStepKind, - { singular: string; plural: string } -> = { - search: { singular: 'search', plural: 'searches' }, - list: { singular: 'listing', plural: 'listings' }, - read: { singular: 'file', plural: 'files' }, -}; +export type GroupedToolDisplayKind = ToolPresentationCategory; const STEP_KIND_DATA_KEYS: Record = { search: [ @@ -289,45 +265,6 @@ function extractLabelFromToolData( return extractStringByKeys(argumentsRecord as Record, keys); } -function isExecuteToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): boolean { - const data = msg.data as unknown as Record; - return ( - msg.data.kind === 'execute' || - msg.data.kind === 'execute_command' || - data.isExecute === true - ); -} - -function resolveExplorationStepKind( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): ExplorationStepKind | null { - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - - for (const stepKind of STEP_KIND_ORDER) { - if (toolName && EXPLORATION_TOOL_NAMES[stepKind].has(toolName)) { - return stepKind; - } - } - - if (msg.data.kind === 'search') { - return 'search'; - } - - if (msg.data.kind === 'list') { - return 'list'; - } - - if (msg.data.kind === 'read') { - return 'read'; - } - - return null; -} - /** * Stable identity for consecutive same-type collapsing. Different tools never * share a key, even when both are MCP exploration-style helpers. @@ -340,49 +277,14 @@ function resolveToolGroupKey( return null; } - if (isExecuteToolMessage(msg)) { - return 'execute'; - } - - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - const serverName = (msg.data.serverName ?? msg.data.mcpServerName ?? '') - .trim() - .toLowerCase(); - - if (toolName) { - return serverName ? `mcp:${serverName}:${toolName}` : `tool:${toolName}`; - } - - const kind = (msg.data.kind ?? '').trim().toLowerCase(); - - if (kind && kind !== 'mcp') { - return `kind:${kind}`; - } - - return null; + return resolveToolPresentation(msg.data, msg.partial).groupKey; } function resolveGroupedToolDisplayKind( msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - groupKey: string, + _groupKey: string, ): GroupedToolDisplayKind { - if (groupKey === 'execute' || isExecuteToolMessage(msg)) { - return 'execute'; - } - - const explorationStep = resolveExplorationStepKind(msg); - - if (explorationStep) { - return explorationStep; - } - - if (msg.data.kind === 'edit') { - return 'edit'; - } - - return 'tool'; + return resolveToolPresentation(msg.data, msg.partial).category; } function isSettledToolMessage( @@ -395,10 +297,6 @@ function isSettledToolMessage( return msg.data.status === 'completed' || msg.data.status === 'failed'; } -function formatGenericToolLabel(value: string): string { - return value.split(/[_-]+/).filter(Boolean).join(' ').toLowerCase(); -} - const TITLE_PREFIX_RE = /^(?:search|read|list|find|run|using|used|ran|running)\s+(.+)$/i; @@ -437,53 +335,14 @@ function extractObjectLabel( function summarizeSameTypeGroup( items: GroupedToolCallItem[], displayKind: GroupedToolDisplayKind, - groupKey: string, + _groupKey: string, ): { action: string; objectSummary: string } { - const count = items.length; - - if (displayKind === 'execute') { - return { - action: 'Ran', - objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, - }; - } - - if ( - displayKind === 'search' || - displayKind === 'list' || - displayKind === 'read' - ) { - const labels = STEP_KIND_LABELS[displayKind]; - return { - action: 'Exploring', - objectSummary: `${count} ${count === 1 ? labels.singular : labels.plural}`, - }; - } - - if (displayKind === 'edit') { - return { - action: 'Edited', - objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, - }; - } - - const toolNameMatch = /^(?:mcp:[^:]+:|tool:)(.+)$/.exec(groupKey); - const toolLabel = toolNameMatch?.[1] - ? formatGenericToolLabel(toolNameMatch[1]) - : null; - - if (toolLabel) { - return { - action: 'Used', - objectSummary: - count === 1 ? `1 ${toolLabel}` : `${count} ${toolLabel} calls`, - }; - } - - return { - action: 'Used', - objectSummary: `${count} ${count === 1 ? 'tool' : 'tools'}`, - }; + const presentation = resolveToolPresentation(items[0]!.msg.data); + return summarizeToolGroup( + displayKind, + items.length, + presentation.displayName, + ); } function buildGroupedToolItem( @@ -682,12 +541,6 @@ function resolveMessageRenderState( options: BuildAcpRenderBlocksOptions, hideCurrentFirstUserPrompt: boolean, ): MessageRenderState { - const shouldShowInternalMessageInNarration = - options.showInternalMessages === true && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)); - const shouldShowWidgetInNarration = - isToolMessage(msg) && resolveShowWidgetForToolMessage(msg) !== null; - if (options.suppressedMessageIds?.has(msg.id)) { return { visibility: 'hidden', @@ -702,32 +555,18 @@ function resolveMessageRenderState( }; } - if ( - options.showInternalMessages === false && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)) && - // Spawn rows render inline even without debug UI. Keyed on the stable - // payload shape, never on live-only activity data: activity does not - // survive a transcript rebuild, and a row that vanishes on refresh reads - // as a lost subagent. - !isSubagentSpawnRowMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; - } - - if ( - options.displayMode === 'narration' && - isToolMessage(msg) && - !shouldShowInternalMessageInNarration && - !shouldShowWidgetInNarration && - !isSubagentToolMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; + if (isToolMessage(msg)) { + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + if (policy.rowVisibility !== 'visible') { + return { + visibility: 'hidden', + behavior: policy.hiddenBehavior, + }; + } } if (isEmptyCompletedTextMessage(msg)) { @@ -759,12 +598,16 @@ function resolveMessageRenderState( }; } + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + return { visibility: 'render', groupKey: - options.keepDelegatedTasksVisible && getDelegatedTaskDetails(msg) - ? null - : resolveToolGroupKey(msg), + policy.groupingMode === 'standalone' ? null : resolveToolGroupKey(msg), }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts index da53510ec..8fffccbd8 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts @@ -1,5 +1,5 @@ -import { isInternalDebugToolCallMessage } from '../../message-visibility'; import { isSubagentToolPayload } from './subagent-tool'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; @@ -69,21 +69,9 @@ export function hidesExpandedToolResult( msg: AcpToolUiMessage, options?: ToolDetailVisibilityOptions, ): boolean { - const data = msg.data as unknown as Record; - - if (isSubagentToolPayload(msg.data)) { - if (options?.showSubagentPayload === true) { - return false; - } - - return ( - getSubagentPrompt(msg) === null && getSubagentLastMessage(msg) === null - ); - } - return ( - isInternalDebugToolCallMessage(msg) || - msg.data.kind === 'read' || - data.isRead === true + resolveToolPresentationPolicy(msg, { + showInternalMessages: options?.showSubagentPayload === true, + }).detailMode !== 'expandable' ); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts new file mode 100644 index 000000000..d61e59431 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts @@ -0,0 +1,67 @@ +import { createElement, forwardRef } from 'react'; +import type { LucideProps } from 'lucide-react'; + +import { + type LucideIcon, + Brain, + BrandIcon, + Bot, + FileIcon, + FolderIcon, + GalleryVerticalEnd, + GitPullRequest, + HardDriveUpload, + ListChecks, + MessageSquareText, + MessagesSquare, + RoomoteR, + Search, + SquarePen, + Target, + Terminal, + TriangleAlert, + VectorSquare, + Video, + Wrench, + Zap, +} from '@/components/system'; + +import type { ToolIconKey } from './tool-presentation'; + +export function toolIconForKey(key: ToolIconKey): LucideIcon { + if (key === 'terminal') return Terminal; + if (key === 'file') return FileIcon; + if (key === 'folder') return FolderIcon; + if (key === 'search') return Search; + if (key === 'edit') return SquarePen; + if (key === 'bot') return Bot; + if (key === 'task') return Zap; + if (key === 'message') return MessageSquareText; + if (key === 'memory') return Brain; + if (key === 'artifact') return HardDriveUpload; + if (key === 'widget') return GalleryVerticalEnd; + if (key === 'roomote') return RoomoteR; + if (key === 'video') return Video; + if (key === 'target') return Target; + if (key === 'list-checks') return ListChecks; + if (key === 'pull-request') return GitPullRequest; + if (key === 'environment') return VectorSquare; + if (key === 'alert') return TriangleAlert; + if (key === 'messages') return MessagesSquare; + return Wrench; +} + +const mcpIntegrationIconCache = new Map(); + +export function mcpIntegrationIconFor(icon: string): LucideIcon { + const existing = mcpIntegrationIconCache.get(icon); + if (existing) return existing; + + const McpIntegrationIcon = forwardRef( + ({ className }, _ref) => + createElement(BrandIcon, { icon, name: '', className }), + ); + McpIntegrationIcon.displayName = `McpIntegrationIcon(${icon})`; + mcpIntegrationIconCache.set(icon, McpIntegrationIcon); + return McpIntegrationIcon; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts new file mode 100644 index 000000000..699a853f1 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts @@ -0,0 +1,140 @@ +import type { TaskArtifact } from '@/types'; + +import { + isInternalDebugToolCallMessage, + shouldHideAcpMessage, +} from '../../message-visibility'; +import { getDelegatedTaskDetails } from './delegated-task'; +import { + isSubagentSpawnRowMessage, + isSubagentToolMessage, +} from './subagent-tool'; +import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; +import { resolveToolPresentation } from './tool-presentation'; +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; +import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface ToolPresentationPolicyOptions { + artifacts?: readonly TaskArtifact[] | null; + delegatedTaskCardsEnabled?: boolean; + displayMode?: 'default' | 'narration'; + showInternalMessages?: boolean; +} + +interface ResolvedToolPolicy { + rowVisibility: 'visible' | 'hidden' | 'debug-only'; + hiddenBehavior: 'boundary' | 'transparent'; + detailMode: 'none' | 'expandable' | 'preview'; + activityMode: 'collapsible' | 'keep-visible'; + renderAs: 'row' | 'delegated-task-card'; + groupingMode: 'groupable' | 'standalone'; +} + +const CONSEQUENTIAL_RECEIPTS = new Set([ + 'launch_task', + 'cancel_task', + 'retry_task_start', + 'send_task_message', + 'save_memory', +]); + +export function resolveToolPresentationPolicy( + msg: ToolMessage, + options: ToolPresentationPolicyOptions = {}, +): ResolvedToolPolicy { + const presentation = resolveToolPresentation(msg.data, msg.partial); + const delegatedTask = getDelegatedTaskDetails(msg); + const renderAs = + options.delegatedTaskCardsEnabled && delegatedTask + ? 'delegated-task-card' + : 'row'; + const isInternal = + isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg); + const showWidget = resolveShowWidgetForToolMessage(msg) !== null; + const visualProof = + resolveVisualProofMediaForToolMessage(msg, options.artifacts).length > 0; + const isArtifact = presentation.category === 'artifact'; + const hasPreview = showWidget || visualProof; + const isRunning = msg.partial || msg.data.status === 'in_progress'; + const consequentialReceipt = + presentation.identity.toolName !== null && + CONSEQUENTIAL_RECEIPTS.has(presentation.identity.toolName); + + let rowVisibility: ResolvedToolPolicy['rowVisibility'] = 'visible'; + if (shouldHideAcpMessage(msg)) { + rowVisibility = 'hidden'; + } else if ( + options.showInternalMessages === false && + isInternal && + !isSubagentSpawnRowMessage(msg) + ) { + rowVisibility = 'debug-only'; + } else if ( + options.displayMode === 'narration' && + !hasPreview && + !isSubagentToolMessage(msg) && + renderAs !== 'delegated-task-card' && + !consequentialReceipt && + !(options.showInternalMessages && isInternal) + ) { + rowVisibility = 'hidden'; + } + + const detailMode: ResolvedToolPolicy['detailMode'] = + isSubagentToolMessage(msg) && hasSubagentSummary(msg) + ? 'expandable' + : hasPreview + ? 'preview' + : isInternalDebugToolCallMessage(msg) || + presentation.category === 'read' || + (isSubagentToolMessage(msg) && + !options.showInternalMessages && + !hasSubagentSummary(msg)) + ? 'none' + : 'expandable'; + + return { + rowVisibility, + hiddenBehavior: 'boundary', + detailMode, + activityMode: + isRunning || + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'keep-visible' + : 'collapsible', + renderAs, + groupingMode: + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'standalone' + : 'groupable', + }; +} + +function hasSubagentSummary(msg: ToolMessage): boolean { + const data = msg.data as unknown as Record; + const prompt = data.prompt; + const rawInput = + data.rawInput && + typeof data.rawInput === 'object' && + !Array.isArray(data.rawInput) + ? (data.rawInput as Record) + : null; + const rawPrompt = rawInput?.prompt; + const output = msg.kind === 'tool_result' ? msg.data.output : null; + const activity = data.subagentActivity; + + return Boolean( + (typeof prompt === 'string' && prompt.trim()) || + (typeof rawPrompt === 'string' && rawPrompt.trim()) || + (typeof output === 'string' && output.trim()) || + (activity && typeof activity === 'object'), + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts new file mode 100644 index 000000000..554037f6b --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -0,0 +1,348 @@ +import { + getMcpIntegration, + type AcpToolCallPayload, + type AcpToolResultPayload, +} from '@roomote/types'; + +import { sanitizeSandboxPathString } from '@/lib'; + +export type ToolPresentationCategory = + | 'execute' + | 'read' + | 'search' + | 'list' + | 'edit' + | 'subagent' + | 'task' + | 'communication' + | 'memory' + | 'artifact' + | 'widget' + | 'generic'; + +export type ToolIconKey = + | 'terminal' + | 'file' + | 'folder' + | 'search' + | 'edit' + | 'bot' + | 'task' + | 'message' + | 'memory' + | 'artifact' + | 'widget' + | 'roomote' + | 'video' + | 'target' + | 'list-checks' + | 'pull-request' + | 'environment' + | 'alert' + | 'messages' + | 'tool'; + +type ToolPresentationPhase = 'running' | 'completed' | 'failed'; + +type ToolData = AcpToolCallPayload | AcpToolResultPayload; + +interface ResolvedToolPresentation { + identity: { + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + }; + category: ToolPresentationCategory; + displayName: string; + iconKey: ToolIconKey; + integrationIcon?: string; + phase: ToolPresentationPhase; + verb: string; + object?: string; + providerLabel?: string; + groupKey: string | null; +} + +const SEARCH_TOOL_NAMES = new Set([ + 'search', + 'search_file', + 'search_files', + 'spill_grep', +]); +const LIST_TOOL_NAMES = new Set([ + 'glob', + 'list', + 'list_dir', + 'list_directory', + 'list_files', + 'list_skills', +]); +const READ_TOOL_NAMES = new Set([ + 'read', + 'read_file', + 'spill_read', + 'load_skill', +]); +const TASK_TOOL_NAMES = new Set([ + 'launch_task', + 'retry_task_start', + 'cancel_task', + 'send_task_message', +]); +const COMMUNICATION_TOOL_NAMES = new Set([ + 'send_chat_reply', + 'send_chat_reaction', + 'send_chat_reaction_emoji', + 'add_reaction_to_slack_message', + 'post_to_channel', + 'ignore_event', +]); +const TOOL_ICON_OVERRIDES: Readonly>> = { + manage_custom_automations: 'task', + get_about_me: 'roomote', + describe_video: 'video', + manage_goal: 'target', + manage_tasks: 'list-checks', + manage_source_control: 'pull-request', + manage_environments: 'environment', + save_task_memory: 'memory', + request_environment_variables: 'terminal', + report_platform_issue: 'alert', + submit_automation_work_items: 'task', + list_chat_channels: 'messages', + get_chat_channel_messages: 'messages', + get_chat_message_context: 'messages', +}; + +function normalized(value: string | null | undefined): string | null { + const result = value?.trim().toLowerCase(); + return result ? result : null; +} + +function formatToolIdentifier(value: string): string { + if (value.toLowerCase() === 'gbrain') return 'Memory'; + + return value + .replace(/[.]/g, ' ') + .replace(/[-_]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (character) => character.toUpperCase()) + .trim(); +} + +export function resolveToolPresentation( + data: ToolData, + partial = false, +): ResolvedToolPresentation { + const serverName = normalized(data.serverName ?? data.mcpServerName); + const toolName = normalized(data.toolName ?? data.mcpToolName); + const kind = normalized(data.kind); + const providerKind = data.isMcp ? 'mcp' : 'native'; + const phase: ToolPresentationPhase = + data.status === 'failed' + ? 'failed' + : data.status === 'in_progress' || partial + ? 'running' + : 'completed'; + const category = resolveToolCategory({ + kind, + toolName, + serverName, + isExecute: data.isExecute, + isRead: 'isRead' in data && data.isRead === true, + isSubagentSpawn: data.isSubagentSpawn === true, + }); + const explicitIconKey = toolName ? TOOL_ICON_OVERRIDES[toolName] : undefined; + const integration = + providerKind === 'mcp' && serverName + ? getMcpIntegration(serverName) + : undefined; + const displayName = toolName + ? formatToolIdentifier(toolName) + : sanitizeSandboxPathString(data.title ?? 'Tool'); + const providerLabel = + integration?.name ?? + (serverName ? formatToolIdentifier(serverName) : undefined); + const receipt = resolveReceiptLanguage(toolName, phase); + const verb = receipt?.verb ?? (phase === 'running' ? 'Using' : 'Used'); + const object = receipt?.object ?? displayName; + + return { + identity: { providerKind, serverName, toolName }, + category, + displayName, + iconKey: explicitIconKey ?? categoryIconKey(category), + integrationIcon: explicitIconKey ? undefined : integration?.icon, + phase, + verb, + object, + providerLabel, + groupKey: resolveToolGroupKey({ + category, + providerKind, + serverName, + toolName, + kind, + }), + }; +} + +function resolveToolCategory(input: { + kind: string | null; + toolName: string | null; + serverName: string | null; + isExecute: boolean; + isRead: boolean; + isSubagentSpawn: boolean; +}): ToolPresentationCategory { + if (input.kind === 'subagent' || input.isSubagentSpawn) return 'subagent'; + if ( + input.kind === 'execute' || + input.kind === 'execute_command' || + input.isExecute + ) + return 'execute'; + if ( + input.kind === 'read' || + input.isRead || + (input.toolName && READ_TOOL_NAMES.has(input.toolName)) + ) + return 'read'; + if ( + input.kind === 'search' || + (input.toolName && SEARCH_TOOL_NAMES.has(input.toolName)) + ) + return 'search'; + if ( + input.kind === 'list' || + (input.toolName && LIST_TOOL_NAMES.has(input.toolName)) + ) + return 'list'; + if (input.kind === 'edit') return 'edit'; + if ( + input.kind === 'task' || + (input.toolName && TASK_TOOL_NAMES.has(input.toolName)) + ) + return 'task'; + if ( + input.kind === 'communication' || + (input.toolName && COMMUNICATION_TOOL_NAMES.has(input.toolName)) + ) + return 'communication'; + if ( + input.kind === 'memory' || + input.serverName === 'gbrain' || + input.toolName === 'save_memory' + ) + return 'memory'; + if (input.kind === 'artifact' || input.toolName === 'manage_artifacts') + return 'artifact'; + if (input.kind === 'widget' || input.toolName === 'show_widget') + return 'widget'; + return 'generic'; +} + +function categoryIconKey(category: ToolPresentationCategory): ToolIconKey { + if (category === 'execute') return 'terminal'; + if (category === 'read') return 'file'; + if (category === 'list') return 'folder'; + if (category === 'search') return 'search'; + if (category === 'edit') return 'edit'; + if (category === 'subagent') return 'bot'; + if (category === 'task') return 'task'; + if (category === 'communication') return 'message'; + if (category === 'memory') return 'memory'; + if (category === 'artifact') return 'artifact'; + if (category === 'widget') return 'widget'; + return 'tool'; +} + +function resolveToolGroupKey(input: { + category: ToolPresentationCategory; + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + kind: string | null; +}): string | null { + if (input.category === 'subagent') return null; + if (input.category === 'execute') return 'execute'; + if (input.toolName) { + return input.providerKind === 'mcp' && input.serverName + ? `mcp:${input.serverName}:${input.toolName}` + : `tool:${input.toolName}`; + } + return input.kind && input.kind !== 'mcp' ? `kind:${input.kind}` : null; +} + +function resolveReceiptLanguage( + toolName: string | null, + phase: ToolPresentationPhase, +): { verb: string; object: string } | null { + const byPhase = (running: string, completed: string, failed: string) => + phase === 'running' ? running : phase === 'failed' ? failed : completed; + + if (toolName === 'launch_task') + return { + verb: byPhase('Starting', 'Started', 'Failed to Start'), + object: 'Coding Task', + }; + if (toolName === 'cancel_task') + return { + verb: byPhase('Cancelling', 'Cancelled', 'Failed to Cancel'), + object: 'Task', + }; + if (toolName === 'retry_task_start') + return { + verb: byPhase('Retrying', 'Retried', 'Failed to Retry'), + object: 'Task', + }; + if (toolName === 'send_task_message') + return { + verb: byPhase('Sending', 'Sent', 'Failed to Send'), + object: 'Task Message', + }; + if (toolName === 'save_memory') + return { + verb: byPhase('Saving', 'Saved', 'Failed to Save'), + object: 'Memory', + }; + return null; +} + +export function summarizeToolGroup( + category: ToolPresentationCategory, + count: number, + displayName: string, +): { action: string; objectSummary: string } { + if (category === 'execute') + return { + action: 'Ran', + objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, + }; + if (category === 'search') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'search' : 'searches'}`, + }; + if (category === 'list') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'listing' : 'listings'}`, + }; + if (category === 'read') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + if (category === 'edit') + return { + action: 'Edited', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + + const label = displayName.toLowerCase(); + return { + action: 'Used', + objectSummary: count === 1 ? `1 ${label}` : `${count} ${label} calls`, + }; +} diff --git a/apps/web/src/components/ai-elements/message.stories.tsx b/apps/web/src/components/ai-elements/message.stories.tsx index db90605ed..cd9d3a890 100644 --- a/apps/web/src/components/ai-elements/message.stories.tsx +++ b/apps/web/src/components/ai-elements/message.stories.tsx @@ -1,14 +1,14 @@ 'use client'; import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { + ACP_TOOL_KINDS, + FAST_AGENT_NATIVE_TOOL_CATALOG, + type KnownAcpToolKind, +} from '@roomote/types'; import { CopyIcon, - Database, - GlobeIcon, - SquarePen, RefreshCwIcon, - SearchIcon, - TerminalIcon, ThumbsDownIcon, ThumbsUpIcon, } from '@/components/system'; @@ -33,6 +33,7 @@ import { import { CollapsibleContent } from './collapsible-content'; import { Reasoning, ReasoningContent, ReasoningTrigger } from './reasoning'; import { AcpCommandOutputMessage } from '@/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage'; +import { AcpMessageItem } from '@/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem'; import { AcpTodoSectionMessage } from '@/app/(sandbox)/task/[taskId]/messages/acp/AcpTodoSectionMessage'; import type { AcpTodoSectionUiMessage, @@ -51,7 +52,6 @@ import { TodoListSectionLabel, TodoListSectionTrigger, } from './todo-list'; -import { Tool, ToolContent, ToolHeader, ToolInput } from './tool'; const meta: Meta = { title: 'Patterns/AI Elements/Conversation/Message', @@ -71,6 +71,165 @@ const meta: Meta = { export default meta; type Story = StoryObj; +type StoryToolDefinition = { + name: string; + kind: KnownAcpToolKind; + command?: string; + isMcp?: boolean; + provider?: string; + rawInput?: Record; +}; + +const EXTERNAL_MCP_TOOL_CALL = { + name: 'search_issues', + kind: ACP_TOOL_KINDS.mcp, + isMcp: true, + provider: 'linear', + rawInput: { query: 'conversation rendering' }, +} as const satisfies StoryToolDefinition; + +const SESSION_TOOL_CALL_CATALOG = [ + ...FAST_AGENT_NATIVE_TOOL_CATALOG, + EXTERNAL_MCP_TOOL_CALL, +] as const satisfies readonly StoryToolDefinition[]; + +const TASK_TOOL_CALL_CATALOG = { + [ACP_TOOL_KINDS.execute]: { + name: 'execute_command', + kind: ACP_TOOL_KINDS.execute, + command: 'pnpm check-types', + }, + [ACP_TOOL_KINDS.read]: { + name: 'read_file', + kind: ACP_TOOL_KINDS.read, + rawInput: { path: 'apps/web/src/components/ai-elements/message.tsx' }, + }, + [ACP_TOOL_KINDS.search]: { + name: 'search_files', + kind: ACP_TOOL_KINDS.search, + rawInput: { query: 'ToolHeader' }, + }, + [ACP_TOOL_KINDS.list]: { + name: 'list_files', + kind: ACP_TOOL_KINDS.list, + rawInput: { path: 'apps/web/src/components/ai-elements' }, + }, + [ACP_TOOL_KINDS.edit]: { + name: 'edit_file', + kind: ACP_TOOL_KINDS.edit, + rawInput: { + path: 'apps/web/src/components/ai-elements/message.stories.tsx', + }, + }, + [ACP_TOOL_KINDS.subagent]: { + name: 'task', + kind: ACP_TOOL_KINDS.subagent, + rawInput: { prompt: 'Inspect the conversation renderer.' }, + }, + [ACP_TOOL_KINDS.task]: { + name: 'manage_tasks', + kind: ACP_TOOL_KINDS.task, + isMcp: true, + provider: 'roomote', + rawInput: { action: 'get_summary', taskId: 'task-storybook' }, + }, + [ACP_TOOL_KINDS.communication]: { + name: 'send_chat_reply', + kind: ACP_TOOL_KINDS.communication, + isMcp: true, + provider: 'roomote', + rawInput: { message: 'The implementation is complete.' }, + }, + [ACP_TOOL_KINDS.memory]: { + name: 'save_task_memory', + kind: ACP_TOOL_KINDS.memory, + isMcp: true, + provider: 'roomote', + rawInput: { outcome: 'Documented the transcript rendering behavior.' }, + }, + [ACP_TOOL_KINDS.artifact]: { + name: 'manage_artifacts', + kind: ACP_TOOL_KINDS.artifact, + isMcp: true, + provider: 'roomote', + rawInput: { action: 'list' }, + }, + [ACP_TOOL_KINDS.widget]: { + name: 'show_widget', + kind: ACP_TOOL_KINDS.widget, + isMcp: true, + provider: 'roomote', + rawInput: { html: '

Tool preview

', title: 'Tool preview' }, + }, + [ACP_TOOL_KINDS.mcp]: EXTERNAL_MCP_TOOL_CALL, + [ACP_TOOL_KINDS.tool]: { + name: 'request_environment_variables', + kind: ACP_TOOL_KINDS.tool, + isMcp: true, + provider: 'roomote', + rawInput: { variables: ['STORYBOOK_TOKEN'] }, + }, +} as const satisfies Record; + +function toolResultMessage( + tool: StoryToolDefinition, + surface: 'session' | 'task', + index: number, +): AcpToolResultUiMessage { + const isExecute = tool.kind === ACP_TOOL_KINDS.execute; + const isMcp = tool.isMcp ?? false; + + return { + id: `${surface}-tool-${index}`, + ts: index + 1, + role: 'tool', + partial: false, + sessionId: `${surface}-storybook`, + updateType: 'roomote_runtime.tool_result', + kind: 'tool_result', + text: '{}', + data: { + toolCallId: `${surface}-tool-call-${index}`, + kind: tool.kind, + title: tool.name, + status: 'completed', + isExecute, + isRead: tool.kind === ACP_TOOL_KINDS.read, + isMcp, + mcpServerName: isMcp ? (tool.provider ?? 'roomote') : null, + mcpToolName: isMcp ? tool.name : null, + serverName: isMcp ? (tool.provider ?? 'roomote') : null, + toolName: tool.name, + command: tool.command ?? null, + exitCode: isExecute ? 0 : null, + output: '{}', + ...(tool.kind === ACP_TOOL_KINDS.subagent + ? { isSubagentSpawn: true, prompt: tool.rawInput?.prompt } + : {}), + ...(tool.rawInput ? { rawInput: tool.rawInput } : {}), + } as AcpToolResultUiMessage['data'], + }; +} + +function ToolCallInventory({ + tools, + surface, +}: { + tools: readonly StoryToolDefinition[]; + surface: 'session' | 'task'; +}) { + return ( +
+ {tools.map((tool, index) => ( + + ))} +
+ ); +} + // --------------------------------------------------------------------------- // Full Conversations // --------------------------------------------------------------------------- @@ -97,7 +256,7 @@ export const FullConversationKitchenSink: Story = { - {/* Assistant reasons, uses tools, shows todo, and responds */} + {/* Assistant reasons, updates the plan, and responds. */} @@ -111,52 +270,6 @@ export const FullConversationKitchenSink: Story = { - - - - - - - - - - {`I've created the JWT service and updated the middleware. Here's a summary: @@ -203,7 +316,7 @@ I'm now working on refresh token rotation. Would you like me to continue?`} - {/* Assistant thinks, runs tests, and queries via MCP */} + {/* Assistant reasons about the validation result. */} @@ -217,70 +330,6 @@ I should: - - - - - - - - NOW()', - }} - /> - - - - - - - - {`All 6 tests pass. I also checked the database — there are **142 active sessions** that will need to be migrated. The latest CI run on GitHub is green. @@ -1256,125 +1305,23 @@ Let me start with the token generation service.`} }; // --------------------------------------------------------------------------- -// Tool Use +// Product Tool Calls // --------------------------------------------------------------------------- -export const WithToolUse: Story = { - name: 'Assistant – With Tool Use', +export const FastSessionToolCalls: Story = { + name: 'Fast Session – All Tool Calls', render: () => ( - - - - - - - - - - - - - - - - - - - {`I've read the existing auth module and created a new JWT service. All tests are passing.`} - - - + ), }; -// --------------------------------------------------------------------------- -// MCP Tool Calls -// --------------------------------------------------------------------------- - -export const WithMcpToolCalls: Story = { - name: 'Assistant – With MCP Tool Calls', +export const TaskToolCalls: Story = { + name: 'Task – All Tool Call Kinds', render: () => ( - - - - - - - - - - - - - - - - - - - {`I found the user record and their linked repositories. The Linear search is still running — once it completes I'll cross-reference the open issues with recent commits.`} - - - + ), }; diff --git a/apps/web/src/components/ai-elements/tool.client.test.tsx b/apps/web/src/components/ai-elements/tool.client.test.tsx index 183dd8bb5..adbf63ff5 100644 --- a/apps/web/src/components/ai-elements/tool.client.test.tsx +++ b/apps/web/src/components/ai-elements/tool.client.test.tsx @@ -1,10 +1,79 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { Search } from '@/components/system'; -import { ToolHeader } from './tool'; +import { Tool, ToolHeader } from './tool'; describe('ToolHeader', () => { + it('shows running and failed states textually while success stays implied', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Running')).not.toHaveClass('sr-only'); + + rerender( + , + ); + expect(screen.getByText('Completed')).toHaveClass('sr-only'); + + rerender( + , + ); + expect(screen.getByText('Failed')).not.toHaveClass('sr-only'); + }); + + it('exposes expansion state only for interactive headers', () => { + const { rerender } = render( + + + , + ); + + const trigger = screen.getByRole('button', { + name: 'Used Search Completed', + }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + rerender( + + + , + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + it('keeps long action-only labels truncatable inside the header row', () => { const action = 'Read /tmp/roomote-tool-header-regression-path-with-a-very-long-file-name.txt'; diff --git a/apps/web/src/components/ai-elements/tool.tsx b/apps/web/src/components/ai-elements/tool.tsx index f2aedbb81..a9e68455b 100644 --- a/apps/web/src/components/ai-elements/tool.tsx +++ b/apps/web/src/components/ai-elements/tool.tsx @@ -15,26 +15,6 @@ import { CollapsibleTrigger, } from '@/components/system'; -// const TOOL_STATE_LABELS: Record = { -// 'input-streaming': 'Pending', -// 'input-available': 'Running', -// 'approval-requested': 'Awaiting Approval', -// 'approval-responded': 'Responded', -// 'output-available': 'Completed', -// 'output-error': 'Error', -// 'output-denied': 'Denied', -// }; - -// const TOOL_STATE_ICONS: Record = { -// 'input-streaming': , -// 'input-available': , -// 'approval-requested': , -// 'approval-responded': , -// 'output-available': , -// 'output-error': , -// 'output-denied': , -// }; - type ToolState = | 'input-streaming' | 'input-available' @@ -44,6 +24,16 @@ type ToolState = | 'output-error' | 'output-denied'; +const TOOL_STATE_LABELS: Record = { + 'input-streaming': 'Running', + 'input-available': 'Running', + 'approval-requested': 'Awaiting approval', + 'approval-responded': 'Responded', + 'output-available': 'Completed', + 'output-error': 'Failed', + 'output-denied': 'Denied', +}; + type ToolProps = ComponentProps; export const Tool = ({ className, ...props }: ToolProps) => ( @@ -71,7 +61,7 @@ export const ToolHeader = ({ suffix, suffixPrefix = 'from', icon: ActionIcon, - state: _state, + state, params: _params, additions, deletions, @@ -83,6 +73,11 @@ export const ToolHeader = ({ (additions !== undefined && additions > 0) || (deletions !== undefined && deletions > 0); const hasSecondaryLabel = Boolean(object || suffix); + const statusLabel = TOOL_STATE_LABELS[state]; + const showStatus = + state === 'input-streaming' || + state === 'input-available' || + state === 'output-error'; const inner = (
)} + + {statusLabel} +
); diff --git a/apps/web/src/components/system/custom/icons/index.ts b/apps/web/src/components/system/custom/icons/index.ts index 72e381233..78083a3ea 100644 --- a/apps/web/src/components/system/custom/icons/index.ts +++ b/apps/web/src/components/system/custom/icons/index.ts @@ -1 +1,2 @@ export * from './astroid'; +export * from './roomote-r'; diff --git a/apps/web/src/components/system/custom/icons/roomote-r.tsx b/apps/web/src/components/system/custom/icons/roomote-r.tsx new file mode 100644 index 000000000..fb0cb9b9f --- /dev/null +++ b/apps/web/src/components/system/custom/icons/roomote-r.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from 'react'; +import type { LucideProps } from 'lucide-react'; + +export const RoomoteR = forwardRef( + ({ color = 'currentColor', size = 24, className, ...props }, ref) => ( + + + + ), +); + +RoomoteR.displayName = 'RoomoteR'; diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index 0ceb03ade..2af787355 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -63,7 +63,6 @@ export { ExternalLink, Eye, EyeOff, - File, FileBox, FileCode, FileDiffIcon, @@ -190,6 +189,7 @@ export { SquareSlashIcon, Stethoscope, Sun, + Target, Terminal, TerminalIcon, ThumbsDown, 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 9f2be5024..d10f0f2f9 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 @@ -503,6 +503,14 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { senderContextPresent: true, }), ); + const userMessage = mocks.upsertMessage.mock.calls + .map(([input]) => input.message) + .find((message) => message.eventType === 'roomote_runtime.user_prompt'); + expect(userMessage?.metadata).toMatchObject({ + userId: 'user-1', + userName: 'Matt', + senderDisplayName: 'Matt', + }); }); it('escapes tag injection in non-Slack sender and message context', async () => { @@ -1913,6 +1921,16 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { expect(canonicalWrites[toolResultIndex]?.turnSeq).toBe( canonicalWrites[toolCallIndex]?.turnSeq, ); + expect(canonicalWrites[toolCallIndex]?.payload).toMatchObject({ + kind: 'task', + toolName: 'launch_task', + status: 'in_progress', + }); + expect(canonicalWrites[toolResultIndex]?.payload).toMatchObject({ + kind: 'task', + toolName: 'launch_task', + status: 'completed', + }); }); it('launches across all repositories when the sentinel is explicit', async () => { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-policy.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-policy.test.ts new file mode 100644 index 000000000..e2c8edc25 --- /dev/null +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-policy.test.ts @@ -0,0 +1,29 @@ +import { ACP_TOOL_KINDS, FAST_AGENT_NATIVE_TOOL_CATALOG } from '@roomote/types'; + +import { + FAST_AGENT_NATIVE_TOOL_NAMES, + getFastAgentNativeAcpKind, +} from '../fast-agent-tool-policy'; + +describe('getFastAgentNativeAcpKind', () => { + it.each(FAST_AGENT_NATIVE_TOOL_CATALOG)( + 'maps every catalogued tool (%s) to its ACP kind', + ({ name, kind }) => { + expect(getFastAgentNativeAcpKind(name)).toBe(kind); + }, + ); + + it.each([ + [FAST_AGENT_NATIVE_TOOL_NAMES.spillRead, ACP_TOOL_KINDS.read], + [FAST_AGENT_NATIVE_TOOL_NAMES.loadSkill, ACP_TOOL_KINDS.read], + [FAST_AGENT_NATIVE_TOOL_NAMES.spillGrep, ACP_TOOL_KINDS.search], + [FAST_AGENT_NATIVE_TOOL_NAMES.listSkills, ACP_TOOL_KINDS.list], + [FAST_AGENT_NATIVE_TOOL_NAMES.launchTask, ACP_TOOL_KINDS.task], + [FAST_AGENT_NATIVE_TOOL_NAMES.sendTaskMessage, ACP_TOOL_KINDS.task], + [FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReply, ACP_TOOL_KINDS.communication], + [FAST_AGENT_NATIVE_TOOL_NAMES.saveMemory, ACP_TOOL_KINDS.memory], + [FAST_AGENT_NATIVE_TOOL_NAMES.showWidget, ACP_TOOL_KINDS.widget], + ])('maps %s to %s', (name, expected) => { + expect(getFastAgentNativeAcpKind(name)).toBe(expected); + }); +}); 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 cedb87177..06bfdc688 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 @@ -76,7 +76,10 @@ import { type FastAgentMcpToolCall, type FastAgentNativeToolCall, } from './fast-agent-native-tool-bridge'; -import { buildFastAgentToolFilter } from './fast-agent-tool-policy'; +import { + buildFastAgentToolFilter, + getFastAgentNativeAcpKind, +} from './fast-agent-tool-policy'; import { callFastAgentIntegration, listFastAgentIntegrations, @@ -895,12 +898,14 @@ export async function answerFastAgentQuestion({ nativeSessionId, mcpServerName = null, mcpToolName = null, + kind = mcpServerName && mcpToolName ? 'mcp' : 'tool', }: { title: string; args: Record; nativeSessionId?: string | null; mcpServerName?: string | null; mcpToolName?: string | null; + kind?: string; }) => { const ordinal = nextToolOrdinal++; const toolCallId = `${turnId}:tool:${ordinal}`; @@ -918,10 +923,10 @@ export async function answerFastAgentQuestion({ payload: { toolCallId, title, - kind: 'tool', + kind, status: 'in_progress', isExecute: false, - isRead: false, + isRead: kind === 'read', isMcp, isRoomoteNativeTool: !isMcp, mcpServerName, @@ -944,6 +949,7 @@ export async function answerFastAgentQuestion({ isMcp, mcpServerName, mcpToolName, + kind, canonicalEvent, }; }; @@ -970,9 +976,10 @@ export async function answerFastAgentQuestion({ payload: { toolCallId: event.toolCallId, title: event.title, - kind: 'tool', + kind: event.kind, status: failed ? 'failed' : 'completed', isExecute: false, + isRead: event.kind === 'read', isMcp: event.isMcp, isRoomoteNativeTool: !event.isMcp, mcpServerName: event.mcpServerName, @@ -1096,6 +1103,7 @@ export async function answerFastAgentQuestion({ visibleInTranscript: !platformEvent, turnSource, userId, + ...(senderDisplayName ? { userName: senderDisplayName } : {}), ...(senderDisplayName ? { senderDisplayName } : {}), ...(senderExternalId ? { senderExternalId } : {}), }, @@ -1827,6 +1835,7 @@ export async function answerFastAgentQuestion({ title: call.name, args: call.args, nativeSessionId: call.sessionId, + kind: getFastAgentNativeAcpKind(call.name), }); try { const result = await executeNativeToolInner(call); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-policy.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-policy.ts index f11d46304..cf9161085 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-policy.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-tool-policy.ts @@ -1,21 +1,14 @@ -export const FAST_AGENT_NATIVE_TOOL_NAMES = { - cancelTask: 'cancel_task', - ignoreEvent: 'ignore_event', - launchTask: 'launch_task', - retryTaskStart: 'retry_task_start', - saveMemory: 'save_memory', - sendChatReaction: 'send_chat_reaction', - sendChatReply: 'send_chat_reply', - sendTaskMessage: 'send_task_message', - listSkills: 'list_skills', - loadSkill: 'load_skill', - showWidget: 'show_widget', - spillGrep: 'spill_grep', - spillRead: 'spill_read', -} as const; +import { + FAST_AGENT_NATIVE_TOOL_NAMES, + getFastAgentNativeAcpKind, + type FastAgentNativeToolName, +} from '@roomote/types'; -export type FastAgentNativeToolName = - (typeof FAST_AGENT_NATIVE_TOOL_NAMES)[keyof typeof FAST_AGENT_NATIVE_TOOL_NAMES]; +export { + FAST_AGENT_NATIVE_TOOL_NAMES, + getFastAgentNativeAcpKind, + type FastAgentNativeToolName, +}; export const FAST_AGENT_NATIVE_TOOL_FILTER: Record = { '*': false, diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts index 91726f49a..a9455af76 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -883,6 +883,26 @@ export type AcpToolCallPayloadKind = | string | null; +/** Stable machine-level tool facts. User-facing labels and icons belong to UI clients. */ +export const ACP_TOOL_KINDS = { + execute: 'execute', + read: 'read', + search: 'search', + list: 'list', + edit: 'edit', + subagent: 'subagent', + task: 'task', + communication: 'communication', + memory: 'memory', + artifact: 'artifact', + widget: 'widget', + mcp: 'mcp', + tool: 'tool', +} as const; + +export type KnownAcpToolKind = + (typeof ACP_TOOL_KINDS)[keyof typeof ACP_TOOL_KINDS]; + export interface AcpSessionUpdate extends Record { sessionUpdate: string; } @@ -1006,6 +1026,7 @@ export interface AcpToolResultPayload { kind: AcpToolCallPayloadKind; title: string | null; isExecute: boolean; + isRead?: boolean; isMcp: boolean; /** Trusted Roomote-native tool output persisted by the Fast runtime. */ isRoomoteNativeTool?: boolean; diff --git a/packages/types/src/fast-agent-tool-catalog.ts b/packages/types/src/fast-agent-tool-catalog.ts new file mode 100644 index 000000000..7896494a4 --- /dev/null +++ b/packages/types/src/fast-agent-tool-catalog.ts @@ -0,0 +1,73 @@ +import { ACP_TOOL_KINDS, type KnownAcpToolKind } from './acp'; + +/** + * Native tools exposed by Fast sessions. Keep this catalog in the shared + * contract so runtime policy and transcript fixtures describe the same set. + */ +export const FAST_AGENT_NATIVE_TOOL_NAMES = { + cancelTask: 'cancel_task', + ignoreEvent: 'ignore_event', + launchTask: 'launch_task', + retryTaskStart: 'retry_task_start', + saveMemory: 'save_memory', + sendChatReaction: 'send_chat_reaction', + sendChatReply: 'send_chat_reply', + sendTaskMessage: 'send_task_message', + listSkills: 'list_skills', + loadSkill: 'load_skill', + showWidget: 'show_widget', + spillGrep: 'spill_grep', + spillRead: 'spill_read', +} as const; + +export type FastAgentNativeToolName = + (typeof FAST_AGENT_NATIVE_TOOL_NAMES)[keyof typeof FAST_AGENT_NATIVE_TOOL_NAMES]; + +export const FAST_AGENT_NATIVE_TOOL_CATALOG = [ + { name: FAST_AGENT_NATIVE_TOOL_NAMES.cancelTask, kind: ACP_TOOL_KINDS.task }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.ignoreEvent, + kind: ACP_TOOL_KINDS.communication, + }, + { name: FAST_AGENT_NATIVE_TOOL_NAMES.launchTask, kind: ACP_TOOL_KINDS.task }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.retryTaskStart, + kind: ACP_TOOL_KINDS.task, + }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.saveMemory, + kind: ACP_TOOL_KINDS.memory, + }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReaction, + kind: ACP_TOOL_KINDS.communication, + }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReply, + kind: ACP_TOOL_KINDS.communication, + }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.sendTaskMessage, + kind: ACP_TOOL_KINDS.task, + }, + { name: FAST_AGENT_NATIVE_TOOL_NAMES.listSkills, kind: ACP_TOOL_KINDS.list }, + { name: FAST_AGENT_NATIVE_TOOL_NAMES.loadSkill, kind: ACP_TOOL_KINDS.read }, + { + name: FAST_AGENT_NATIVE_TOOL_NAMES.showWidget, + kind: ACP_TOOL_KINDS.widget, + }, + { name: FAST_AGENT_NATIVE_TOOL_NAMES.spillGrep, kind: ACP_TOOL_KINDS.search }, + { name: FAST_AGENT_NATIVE_TOOL_NAMES.spillRead, kind: ACP_TOOL_KINDS.read }, +] as const satisfies readonly { + name: FastAgentNativeToolName; + kind: KnownAcpToolKind; +}[]; + +export function getFastAgentNativeAcpKind( + name: FastAgentNativeToolName, +): KnownAcpToolKind { + return ( + FAST_AGENT_NATIVE_TOOL_CATALOG.find((tool) => tool.name === name)?.kind ?? + ACP_TOOL_KINDS.tool + ); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ea86228e2..012de4098 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,6 +13,7 @@ export * from './pr-review-action'; export * from './task-runs'; export * from './sessions'; export * from './fast-agent'; +export * from './fast-agent-tool-catalog'; export * from './chatgpt-subscription'; export * from './github-copilot-subscription'; export * from './xai-subscription'; From 0d16f3235792ed4361ebe0808472d31e2e41d8b9 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:33:52 -0400 Subject: [PATCH 36/39] Fix re-review findings on the sessions remediation - Delegated task cards poll until the run actually exits; the server's omitted refetchInterval is not a settled signal - Guard the session detail route param as a uuid so garbage links 404 instead of 500ing on a Postgres 22P02 - The URL ?task= selection now truly wins over the nested panel, so execution-card clicks always show the selected task - ensureSessionForTask verifies a payload fast-conversation id exists before referencing it, so a stale alias can no longer abort a launch - Session title sync compares against a fresh in-transaction snapshot (trim-aware), removing the permanent title-freeze race - The responding lease is refreshed on streamed assistant messages, so long turns no longer flip to ready mid-response - The reconcile job survives poisoned rows (per-item isolation), heals expired leases beyond the top-100 recency window, resumes a legacy fast_tasks backfill phase, and bounds its orphan scans with a lastRunAt watermark instead of scanning whole tables every minute - Discord emoji-reaction entries enter the fast agent like Slack's, anchored on the reacted-on message with its text as context - Drop the dead per-task access-check N+1 from sessions.byId and the duplicate linked-tasks fetch in getSessionById; share the external message predicate between unread and markRead; markRead rejects half-provided cursors; markRead double-fire throttled - Retire the orphaned ?environment= sessions filter end to end - Session surface labels derive from getTaskSurfaceLabel (fixes the Bitbucket vs Bitbucket Cloud drift); status labels go through getSessionStatusLabel everywhere; formatRepositoryName is the one sentinel mapping (adopted by task filters and PR labels) - Retire the picker's Auto option (identical to Fast since routing was removed); stored auto preferences submit as Fast; normalize legacy recent-session localStorage entries --- .../discord/__tests__/fast-agent.test.ts | 59 +++++++ .../handlers/discord/__tests__/index.test.ts | 135 ++++++++++++---- apps/api/src/handlers/discord/fast-agent.ts | 15 +- apps/api/src/handlers/discord/index.ts | 42 ++++- .../__tests__/sessions-reconcile.test.ts | 101 ++++++++++++ .../src/scheduled-jobs/sessions-reconcile.ts | 150 ++++++++++++++---- .../(authenticated)/home/Home.client.test.tsx | 3 +- .../web/src/app/(authenticated)/home/Home.tsx | 4 +- .../src/app/(authenticated)/sessions/page.tsx | 10 +- .../src/app/(authenticated)/tasks/Tasks.tsx | 10 +- .../sessions/[sessionId]/SessionWorkspace.tsx | 39 +++-- .../sessions/[sessionId]/page.test.tsx | 57 ++++--- .../(sandbox)/sessions/[sessionId]/page.tsx | 42 +++-- .../acp/DelegatedTaskCard.client.test.tsx | 16 +- .../messages/acp/DelegatedTaskCard.tsx | 15 +- .../components/sessions/session-surfaces.ts | 42 +++-- apps/web/src/hooks/useMarkSessionRead.ts | 8 +- apps/web/src/hooks/useRecentSessions.ts | 25 ++- apps/web/src/lib/formatters.ts | 3 +- .../src/lib/server/analytics/session-rows.ts | 4 +- apps/web/src/lib/server/sessions.ts | 114 +++++++------ apps/web/src/trpc/commands/filters/index.ts | 8 +- .../src/trpc/commands/sessions/index.test.ts | 31 ++-- apps/web/src/trpc/commands/sessions/index.ts | 32 +--- apps/web/src/trpc/routers/_app.ts | 15 +- .../server/fast-agent/fast-agent-constants.ts | 5 + .../fast-agent-conversation-repository.ts | 15 +- .../server/fast-agent/fast-agent-service.ts | 9 +- .../src/server/fast-agent/fast-agent-title.ts | 28 +++- .../db/src/lib/__tests__/sessions.test.ts | 25 +++ packages/db/src/lib/sessions.ts | 25 ++- .../src/fast-agent-live-task-launcher.ts | 2 +- 32 files changed, 800 insertions(+), 289 deletions(-) diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index df5e2127b..c39793c95 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -278,6 +278,65 @@ describe('processDiscordFastAgentMessage', () => { }, ); + it('anchors the thread and replies on an explicit anchor message (reaction summons)', async () => { + const provider = { + createThreadFromMessage: vi.fn().mockResolvedValue({ + channelId: 'reacted-1', + parentChannelId: 'channel-1', + name: 'Investigate this', + kind: 'thread', + messageId: 'reacted-1', + }), + editMessage: vi.fn().mockResolvedValue(undefined), + }; + mocks.answerQuestion.mockResolvedValueOnce('A quick answer'); + + await processDiscordFastAgentMessage({ + event: { eventId: 'synthetic-1' } as never, + question: 'Investigate this', + sender: { id: 'discord-user-1', username: 'matt' } as never, + senderUserId: 'user-1', + provider: provider as never, + applicationId: 'application-1', + channel: { + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }, + metadata: { + communicationChannelId: 'channel-1', + communicationMessageId: 'reacted-1', + communicationAnchorMessageId: 'reacted-1', + communicationGuildId: 'guild-1', + } as never, + conversationId: 'reacted-1', + anchorMessageId: 'reacted-1', + }); + + // The synthesized message id ('source-1' from getDiscordMessageCreate) is + // not a real Discord message; the reacted-on message anchors everything. + expect(provider.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'reacted-1', + name: 'Investigate this', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ currentMessageId: 'reacted-1' }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: expect.objectContaining({ + channelId: 'reacted-1', + isThread: true, + }), + replyToMessageId: 'reacted-1', + }), + ); + }); + it('continues an existing guild thread without creating another thread', async () => { const provider = { createThreadFromMessage: vi.fn(), diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 11037cbf5..d71a1d310 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => ({ suggestionReaction: vi.fn(), getTaskUrl: vi.fn(), getChannel: vi.fn(), + getMessage: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(), createDirectMessage: vi.fn(), @@ -197,6 +198,7 @@ app.route('/api/internal/discord', discord); const provider = { getChannel: mocks.getChannel, + getMessage: mocks.getMessage, addReaction: mocks.addReaction, removeReaction: mocks.removeReaction, createDirectMessage: mocks.createDirectMessage, @@ -304,6 +306,7 @@ describe('Discord Gateway event handler', () => { mocks.findCompletedRun.mockResolvedValue(null); mocks.findAutomationReportRun.mockResolvedValue(null); mocks.findSourceRun.mockResolvedValue(null); + mocks.getMessage.mockResolvedValue(null); mocks.removeReaction.mockResolvedValue(undefined); mocks.processAttachments.mockResolvedValue({ images: [], @@ -431,7 +434,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('turns a configured reaction into a thread task entry', async () => { + it('routes a configured reaction into the fast agent in a thread anchored on the reacted-on message', async () => { mocks.callViaEmojiConfig.mockResolvedValue({ emoji: 'white_check_mark', prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', @@ -442,6 +445,14 @@ describe('Discord Gateway event handler', () => { type: 0, guildId: 'guild-1', }); + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-1', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); const response = await postEvent({ eventId: 'channel-1:message-1:discord-user-1:white_check_mark', @@ -460,28 +471,85 @@ describe('Discord Gateway event handler', () => { }); expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); expect(mocks.channelAutoStart).not.toHaveBeenCalled(); - expect(mocks.addReaction).toHaveBeenCalledWith({ + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-1', - name: '👀', }); - expect(mocks.startNewTask).toHaveBeenCalledWith( + // The fast thread anchors on the real reacted-on message, not the + // synthesized event id. + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'message-1', + name: expect.stringContaining('Act on this'), + }); + expect(mocks.answerFast).toHaveBeenCalledWith( expect.objectContaining({ - requesterDiscordUserId: 'discord-user-1', - launchOwnerUserId: 'roomote-user-1', - queuedMessage: expect.objectContaining({ - text: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', - }), - metadata: expect.objectContaining({ - communicationMessageId: 'message-1', - communicationAnchorMessageId: 'message-1', + question: + 'Act on this\n\nAdditional instructions:\nPrioritize safety.\n\nMessage to act on:\nDeploys are failing on main', + userId: 'roomote-user-1', + currentMessageId: 'message-1', + conversation: expect.objectContaining({ + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'message-1', }), + }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: 'message-1', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-1', + text: expect.stringContaining('A quick answer'), }), ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.queueMessage).not.toHaveBeenCalled(); + }); + + it('answers a configured reaction through the fast agent when the reacted-on message cannot be fetched', async () => { + mocks.callViaEmojiConfig.mockResolvedValue({ + emoji: 'white_check_mark', + prompt: 'Act on this', + }); + mocks.getChannel.mockResolvedValue({ + id: 'channel-1', + name: 'general', + type: 0, + guildId: 'guild-1', + }); + mocks.getMessage.mockRejectedValue(new Error('rate limited')); + + const response = await postEvent({ + eventId: 'channel-1:message-1:discord-user-1:white_check_mark', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'channel-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'white_check_mark' }, + member: { + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ question: 'Act on this' }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('starts an exactly tracked suggestion before configured emoji routing', async () => { @@ -2721,27 +2789,40 @@ describe('Discord Gateway event handler', () => { }, }; + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-target', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); + const response = await postEvent( envelope(interaction, 'INTERACTION_CREATE'), ); expect(response.status).toBe(200); - expect(mocks.startNewTask).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - communicationMessageId: 'message-target', - communicationAnchorMessageId: 'message-target', - }), - replyToMessageId: 'message-target', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-target', - }), - ); - expect(mocks.addReaction).toHaveBeenCalledWith({ + // The replayed reaction summon enters the fast agent anchored on the + // reacted-on message, matching direct reaction entry. + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-target', - name: '👀', }); + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'channel-1', + messageId: 'message-target', + }), + ); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ + question: + 'Act on this\n\nMessage to act on:\nDeploys are failing on main', + currentMessageId: 'message-target', + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('requires /link in a DM without consuming the one-shot code', async () => { diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index 023ac8e0a..863a5eb0f 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -85,14 +85,23 @@ export async function processDiscordFastAgentMessage(input: { metadata: ReturnType; conversationId: string; createAnchoredThread?: boolean; + /** + * The real Discord message replies and anchored threads attach to. Defaults + * to the inbound message's own id; reaction summons pass the reacted-on + * message because their synthesized message id is not a real Discord + * message. + */ + anchorMessageId?: string; interaction?: DiscordInteractionReplyContext; activeTasks?: { taskId: string }[]; }): Promise { const message = getDiscordMessageCreate(input.event); + const anchorMessageId = input.anchorMessageId ?? message?.id; let channel = input.channel; let metadata = input.metadata; if ( message && + anchorMessageId && input.createAnchoredThread !== false && !channel.isDirectMessage && !channel.isThread && @@ -100,7 +109,7 @@ export async function processDiscordFastAgentMessage(input: { ) { const thread = await input.provider.createThreadFromMessage({ channelId: channel.channelId, - messageId: message.id, + messageId: anchorMessageId, name: buildCommunicationTaskThreadName(input.question), }); channel = { @@ -179,7 +188,7 @@ export async function processDiscordFastAgentMessage(input: { applicationId: input.applicationId, channel, ...(input.interaction ? { interaction: input.interaction } : {}), - ...(message ? { replyToMessageId: message.id } : {}), + ...(anchorMessageId ? { replyToMessageId: anchorMessageId } : {}), text: textWithFooter, }); await recordFastAgentConversationMessageBestEffort({ @@ -218,7 +227,7 @@ export async function processDiscordFastAgentMessage(input: { userId: input.senderUserId, apiBaseUrl, conversation, - currentMessageId: message?.id ?? input.interaction?.interaction.id, + currentMessageId: anchorMessageId ?? input.interaction?.interaction.id, signal: releaseFastAgentLock.signal, senderDisplayName: input.interaction?.interaction.member?.nick ?? diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index ac4ce6cae..2657563bb 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -744,13 +744,11 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); - // Fast mode is unconditional for ordinary linked-human messages. Reaction - // entries carry a configured task prompt, so they keep launching tasks - // (mirroring Slack's call-roomote-via-emoji flow). + // Fast mode is unconditional for ordinary linked-human messages, including + // reaction summons: a configured emoji synthesizes a bot mention that enters + // the fast agent, matching Slack's call-roomote-via-emoji flow. const defaultFastMessage = - message != null && command == null && reactionTarget == null - ? message - : null; + message != null && command == null ? message : null; if (command?.name === 'goal') { if (!command.objective) { @@ -811,6 +809,11 @@ async function processDiscordGatewayEvent( conversationId: repliedFastSession?.conversation.conversationId ?? channel.channelId, ...(repliedFastSession ? { createAnchoredThread: false } : {}), + // A reaction summon's synthesized message id is not a real Discord + // message; anchor replies on the reacted-on message instead. + ...(reactionTarget + ? { anchorMessageId: reactionTarget.messageId } + : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastContinued: true }; @@ -823,19 +826,42 @@ async function processDiscordGatewayEvent( ) : ''; if (defaultFastMessage && defaultFastQuestion) { + let fastQuestion = defaultFastQuestion; + if (reactionTarget) { + // Match Slack's emoji summon: inline the reacted-on message so the fast + // agent sees what it was asked to act on even without thread history. + try { + const targetMessage = await resolved.provider.getMessage({ + channelId: reactionTarget.channelId, + messageId: reactionTarget.messageId, + }); + if (targetMessage?.text) { + fastQuestion = `${defaultFastQuestion}\n\nMessage to act on:\n${targetMessage.text}`; + } + } catch (error) { + apiLogger.warn( + `[discord] Could not resolve emoji summon target ${reactionTarget.channelId}:${reactionTarget.messageId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } await processDiscordFastAgentMessage({ event, - question: defaultFastQuestion, + question: fastQuestion, sender, senderUserId, provider: resolved.provider, applicationId: resolved.applicationId, channel, metadata, + // A reaction summon anchors its fast conversation (and any created + // thread) on the reacted-on message, mirroring Slack threading under + // the reacted-on message; the synthesized message id is not a real + // Discord message. conversationId: getDiscordFastConversationId( channel, - defaultFastMessage.id, + reactionTarget?.messageId ?? defaultFastMessage.id, ), + ...(reactionTarget ? { anchorMessageId: reactionTarget.messageId } : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastDefaulted: true }; diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts index 892be01d4..c8acbd77b 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -2,6 +2,8 @@ import { db, eq, fastAgentConversations, + sessionBackfillState, + sessionFactory, sessionTasks, sessions, taskFactory, @@ -9,6 +11,8 @@ import { } from '@roomote/db/server'; import { sessionsReconcileJob } from '../sessions-reconcile'; +const BACKFILL_KEY = 'unified-sessions-v1'; + describe('sessionsReconcileJob', () => { it('backfills Fast conversations and visible tasks idempotently', async () => { const user = await userFactory.create(); @@ -63,4 +67,101 @@ describe('sessionsReconcileJob', () => { .where(eq(sessions.fastConversationId, conversation!.id)), ).resolves.toHaveLength(1); }); + + it('resumes a backfill parked in the legacy fast_tasks phase', async () => { + await db + .insert(sessionBackfillState) + .values({ key: BACKFILL_KEY, phase: 'fast_tasks' }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: 'fast_tasks', + cursorCreatedAt: null, + cursorId: null, + completedAt: null, + }, + }); + const user = await userFactory.create(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + expect(state?.completedAt).not.toBeNull(); + }); + + it('continues past a poisoned row during steady-state reconciliation', async () => { + // Ensure the backfill is complete so the steady-state path runs. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + // A surface value the sessions check constraint rejects makes + // ensureSessionForFastConversation throw for this row only. + const [poisoned] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'bogus' as never, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const [healthy] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await expect(sessionsReconcileJob()).resolves.toBeUndefined(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, healthy!.id)), + ).resolves.toHaveLength(1); + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, poisoned!.id)), + ).resolves.toHaveLength(0); + + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, poisoned!.id)); + }); + + it('heals sessions wedged active on an expired responding lease', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const wedged = await sessionFactory.create({ + cachedStatus: 'active', + respondingUntil: new Date(Date.now() - 60_000), + // Old activity keeps it clear of the recent-activity refresh window. + activityAt: 100, + }); + + await sessionsReconcileJob(); + + const [healed] = await db + .select({ cachedStatus: sessions.cachedStatus }) + .from(sessions) + .where(eq(sessions.id, wedged.id)); + expect(healed?.cachedStatus).toBe('ready'); + + await db.delete(sessions).where(eq(sessions.id, wedged.id)); + }); }); diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts index ce8591dde..1a26b49a6 100644 --- a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -7,7 +7,9 @@ import { eq, fastAgentConversations, gt, + inArray, isNull, + lt, or, sessionBackfillState, sessions, @@ -20,6 +22,8 @@ import { const LOG_PREFIX = '[sessions]'; const BACKFILL_KEY = 'unified-sessions-v1'; const BATCH_SIZE = 100; +/** Slack subtracted from the last-run watermark when bounding orphan scans. */ +const ORPHAN_SCAN_SLACK_MS = 60 * 60 * 1000; type Cursor = { createdAt: Date; id: string } | null; @@ -92,7 +96,16 @@ async function backfillFastConversations(cursor: Cursor): Promise { .limit(BATCH_SIZE); for (const row of rows) { - await db.transaction((tx) => ensureSessionForFastConversation(tx, row.id)); + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, row.id), + ); + } catch (error) { + console.error( + `${LOG_PREFIX} backfill failed for fast conversation ${row.id}`, + error, + ); + } } const last = rows.at(-1); @@ -126,21 +139,25 @@ async function backfillTasks(cursor: Cursor): Promise { .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', - }), - ); + try { + 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', + }), + ); + } catch (error) { + console.error(`${LOG_PREFIX} backfill failed for task ${row.id}`, error); + } } const last = rows.at(-1); @@ -169,7 +186,14 @@ async function backfillParticipants(): Promise { console.info(`${LOG_PREFIX} backfill participants complete`); } -async function reconcileRecentSessions(): Promise { +async function reconcileRecentSessions(lastRunAt: Date | null): Promise { + // After backfill completion, bound the steady-state orphan scans to rows + // created since the previous reconcile (with slack) so they stop scanning + // entire tables every run. A null watermark means scan unbounded once. + const cutoff = lastRunAt + ? new Date(lastRunAt.getTime() - ORPHAN_SCAN_SLACK_MS) + : null; + // Fast conversations without a session row (e.g. created before this // release finished its backfill) are adopted here so the unified list // converges without another full backfill. @@ -180,14 +204,26 @@ async function reconcileRecentSessions(): Promise { sessions, eq(sessions.fastConversationId, fastAgentConversations.id), ) - .where(isNull(sessions.id)) + .where( + and( + isNull(sessions.id), + cutoff ? gt(fastAgentConversations.createdAt, cutoff) : undefined, + ), + ) .orderBy(desc(fastAgentConversations.updatedAt)) .limit(BATCH_SIZE); for (const conversation of orphanConversations) { - await db.transaction((tx) => - ensureSessionForFastConversation(tx, conversation.id), - ); + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation.id), + ); + } catch (error) { + console.error( + `${LOG_PREFIX} reconcile failed for fast conversation ${conversation.id}`, + error, + ); + } } const orphanTasks = await db @@ -199,15 +235,23 @@ async function reconcileRecentSessions(): Promise { eq(tasks.visibility, 'visible'), isNull(tasks.deletedAt), isNull(sessionTasks.taskId), + cutoff ? gt(tasks.createdAt, cutoff) : undefined, ), ) .orderBy(desc(tasks.activityAt)) .limit(BATCH_SIZE); for (const task of orphanTasks) { - await db.transaction((tx) => - ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), - ); + try { + await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), + ); + } catch (error) { + console.error( + `${LOG_PREFIX} reconcile failed for task ${task.id}`, + error, + ); + } } const recent = await db @@ -217,13 +261,55 @@ async function reconcileRecentSessions(): Promise { .orderBy(desc(sessions.activityAt)) .limit(BATCH_SIZE); for (const session of recent) { - await touchSessionActivity(db, session.id, session.activityAt); + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} refresh failed for session ${session.id}`, + error, + ); + } } + // Sessions stuck 'active'/'needs_input' on an expired (or missing) lease + // may be older than the top-100-by-activity window; heal them explicitly + // so wedged sessions converge regardless of recency. + const expiredLeases = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where( + and( + eq(sessions.visibility, 'visible'), + inArray(sessions.cachedStatus, ['active', 'needs_input']), + or( + isNull(sessions.respondingUntil), + lt(sessions.respondingUntil, new Date()), + ), + ), + ) + .limit(BATCH_SIZE); + for (const session of expiredLeases) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} lease heal failed for session ${session.id}`, + error, + ); + } + } + + // Advance only the watermark; updateState would clobber completedAt. + await db + .update(sessionBackfillState) + .set({ lastRunAt: new Date(), updatedAt: new Date() }) + .where(eq(sessionBackfillState.key, BACKFILL_KEY)); + console.info(`${LOG_PREFIX} reconciliation`, { orphanFastConversations: orphanConversations.length, orphanVisibleTasks: orphanTasks.length, refreshedSessions: recent.length, + healedExpiredLeases: expiredLeases.length, }); } @@ -232,7 +318,7 @@ export async function sessionsReconcileJob(): Promise { where: eq(sessionBackfillState.key, BACKFILL_KEY), }); if (state?.completedAt) { - await reconcileRecentSessions(); + await reconcileRecentSessions(state.lastRunAt); return; } @@ -246,8 +332,16 @@ export async function sessionsReconcileJob(): Promise { const complete = await backfillFastConversations(cursor); if (!complete) return; } - if (phase === 'fast_conversations' || phase === 'tasks') { - const complete = await backfillTasks(phase === 'tasks' ? cursor : null); + // 'fast_tasks' is the pre-rename name of the tasks phase; deployments that + // ran an earlier build of this branch may still be parked there. + if ( + phase === 'fast_conversations' || + phase === 'fast_tasks' || + phase === 'tasks' + ) { + const complete = await backfillTasks( + phase === 'fast_tasks' || phase === 'tasks' ? cursor : null, + ); if (!complete) return; } await backfillParticipants(); 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 36e3a55f8..5c2b000e2 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -355,7 +355,8 @@ describe('Home', () => { render(); expect(screen.queryByText(/Select agent /)).not.toBeInTheDocument(); - expect(screen.getByTestId('allow-auto')).toHaveTextContent('true'); + // Auto was retired from the picker (identical to Fast); Fast is offered. + expect(screen.getByTestId('allow-auto')).toHaveTextContent('false'); expect(mockUseCreateStandardTaskRun).toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Use auto workspace' })); diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 8513e1389..8aac6117d 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -496,6 +496,8 @@ export function Home({ return; } + // Auto is no longer offered in the picker, but stored workspace + // preferences may still restore it; treat it as Fast. if (isAutoWorkspace) { if (!submission.description && !submission.images?.length) return; await startFastSession({ @@ -566,7 +568,7 @@ export function Home({ >
- {column.replace('_', ' ')} + {getSessionStatusLabel(column)}
{result.sessions diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx index b7e41924c..645172511 100644 --- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx @@ -5,8 +5,6 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; -import { ALL_REPOSITORIES } from '@roomote/types'; - import { type Filter, type TimePeriodFilter, @@ -14,7 +12,11 @@ import { parseTimePeriodParam, } from '@/types'; -import { DEFAULT_VISIBLE_TASK_WORKFLOWS, getTaskCategoryById } from '@/lib'; +import { + DEFAULT_VISIBLE_TASK_WORKFLOWS, + formatRepositoryName, + getTaskCategoryById, +} from '@/lib'; import { cn } from '@/lib/utils'; import { useAuthorizedUser } from '@/hooks/useUser'; @@ -320,7 +322,7 @@ export const Tasks = () => { const pullRequestLabel = pullRequest === HAS_PULL_REQUEST_FILTER_VALUE ? 'Has PR' - : pullRequest.replace(ALL_REPOSITORIES, 'All Repositories'); + : formatRepositoryName(pullRequest); result.push({ type: 'pullRequest', diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 8e1f3edf2..3b96ed536 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -415,26 +415,25 @@ export function SessionWorkspace({ setPanel((previous) => (previous?.kind === kind ? null : { kind })); selectTask(null); }; - const panelContent = - panel?.kind === 'nested' ? ( - - ) : selectedTask ? ( - - ) : panel?.kind === 'tasks' ? ( - - ) : ( - - ); + const panelContent = selectedTask ? ( + + ) : panel?.kind === 'nested' ? ( + + ) : panel?.kind === 'tasks' ? ( + + ) : ( + + ); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); useResponsiveSandboxSidebar(session.id); 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 e7c3aa50e..0aae6fa7f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -27,6 +27,9 @@ vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn() }), useSearchParams: () => new URLSearchParams(), + notFound: () => { + throw new Error('NEXT_NOT_FOUND'); + }, })); vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, @@ -80,7 +83,7 @@ describe('Session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-1', + id: '6a1f8f1e-0000-4000-8000-000000000001', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -132,7 +135,9 @@ describe('Session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-1' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', + }), }), ); @@ -142,7 +147,7 @@ describe('Session detail page', () => { expect(html).not.toContain('OpenCode workspace details unavailable'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-1', + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', canReply: true, fallbackTitle: 'Question', initialMessages: expect.arrayContaining([ @@ -160,7 +165,7 @@ describe('Session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-2', + id: '6a1f8f1e-0000-4000-8000-000000000003', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -180,14 +185,16 @@ describe('Session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-2' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', + }), }), ); expect(html).not.toContain('b3b0a53e-6dab-4bb8-b3a5-111111111111'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-2', + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', canReply: true, initialTitle: 'Rotate the API keys', fallbackTitle: 'New session', @@ -203,13 +210,13 @@ describe('Session detail page', () => { isAdmin: false, }); getSessionByIdCommandMock.mockResolvedValue({ - id: 'unified-session-1', + id: '6a1f8f1e-0000-4000-8000-000000000002', title: 'Session title', ownerName: 'User', ownerEmail: 'user@example.com', ownerImageUrl: null, sourceSurface: 'slack', - fastConversationId: 'fast-session-3', + fastConversationId: '6a1f8f1e-0000-4000-8000-000000000005', inferenceCostMicroUsd: 0, createdAt: new Date('2026-01-01T00:00:00.000Z'), status: 'active', @@ -221,7 +228,7 @@ describe('Session detail page', () => { ], }); getFastSessionByIdMock.mockResolvedValue({ - id: 'fast-session-3', + id: '6a1f8f1e-0000-4000-8000-000000000005', ownerName: 'User', ownerEmail: 'user@example.com', surface: 'slack', @@ -235,23 +242,25 @@ describe('Session detail page', () => { renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'unified-session-1' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000002', + }), }), ); expect(getSessionByIdCommandMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), - 'unified-session-1', + '6a1f8f1e-0000-4000-8000-000000000002', ); expect(getFastSessionByIdMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), - 'fast-session-3', + '6a1f8f1e-0000-4000-8000-000000000005', ); expect(getFastSessionTasksMock).not.toHaveBeenCalled(); expect(sessionWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ session: expect.objectContaining({ - id: 'unified-session-1', + id: '6a1f8f1e-0000-4000-8000-000000000002', status: 'active', tasks: [expect.objectContaining({ taskId: 'task-1' })], }), @@ -260,7 +269,7 @@ describe('Session detail page', () => { ); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'fast-session-3', + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', canReply: true, initialTitle: 'Session title', fallbackTitle: 'Session title', @@ -276,7 +285,7 @@ describe('Session detail page', () => { isAdmin: false, }); getSessionByIdCommandMock.mockResolvedValue({ - id: 'unified-session-2', + id: '6a1f8f1e-0000-4000-8000-000000000004', title: 'Task-only session', ownerName: 'User', ownerEmail: 'user@example.com', @@ -296,7 +305,9 @@ describe('Session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'unified-session-2' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000004', + }), }), ); @@ -312,7 +323,7 @@ describe('Session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'fast-session-3', + id: '6a1f8f1e-0000-4000-8000-000000000005', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -330,26 +341,28 @@ describe('Session detail page', () => { renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'fast-session-3' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + }), }), ); expect(getSessionByIdCommandMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), - 'fast-session-3', + '6a1f8f1e-0000-4000-8000-000000000005', ); expect(getFastSessionByIdMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), - 'fast-session-3', + '6a1f8f1e-0000-4000-8000-000000000005', ); expect(getFastSessionTasksMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), - 'fast-session-3', + '6a1f8f1e-0000-4000-8000-000000000005', ); expect(sessionWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ session: expect.objectContaining({ - id: 'fast-session-3', + id: '6a1f8f1e-0000-4000-8000-000000000005', taskSource: 'fast', taskCards: [expect.objectContaining({ taskId: 'task-1' })], }), diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index 4dc2b52b7..3f4cfb550 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from 'next/navigation'; +import { z } from 'zod'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; import { @@ -33,6 +34,11 @@ export default async function SessionDetailPage({ if (!authorizedUser.success) { notFound(); } + // Both lookup columns are uuid; a garbage route param would otherwise throw + // 22P02 in Postgres instead of 404ing. + if (!z.string().uuid().safeParse(sessionId).success) { + notFound(); + } // Old links may carry a fast-conversation id whose session row hasn't been // backfilled yet; getSessionByIdCommand falls back by fastConversationId, @@ -46,17 +52,20 @@ export default async function SessionDetailPage({ : unifiedSession ? null : await getFastSessionById(authorizedUser, sessionId); + // The chip's "default" must reflect what Fast actually runs with: the + // deployment's orchestration model, not the task launch default. + 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; + 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, @@ -122,19 +131,6 @@ export default async function SessionDetailPage({ notFound(); } - // The chip's "default" must reflect what Fast actually runs with: the - // deployment's orchestration model, not the task launch default. - 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: session.id, ownerName: session.ownerName, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx index 381ac1c62..f602bf74c 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx @@ -51,15 +51,25 @@ describe('DelegatedTaskCard', () => { expect(onOpen).toHaveBeenCalledWith('child-1'); const queryOptions = queryOptionsMock.mock.calls[0]![1]; - // The server's refetchInterval drives polling; its absence means stop. + // Server-provided interval wins; otherwise poll until the run exits. + expect( + queryOptions.refetchInterval({ + state: { data: { refetchInterval: 1_500 } }, + }), + ).toBe(1_500); expect(queryOptions.refetchInterval({ state: { data: undefined } })).toBe( - false, + 2_000, ); expect( queryOptions.refetchInterval({ - state: { data: { refetchInterval: 2_000 } }, + state: { data: { taskRun: { status: 'running' } } }, }), ).toBe(2_000); + expect( + queryOptions.refetchInterval({ + state: { data: { taskRun: { status: 'completed' } } }, + }), + ).toBe(false); expect(queryOptionsMock).toHaveBeenCalledWith( { taskId: 'child-1' }, expect.any(Object), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx index 5137a1de9..2d713329d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx @@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query'; +import { isExitedRunStatus } from '@roomote/types'; + import { ChevronRight, Skeleton } from '@/components/system'; import { TaskStatusIndicator } from '@/components/sandbox'; import { useTRPC } from '@/trpc/client'; @@ -20,9 +22,16 @@ export function DelegatedTaskCard({ trpc.sandboxSession.byTaskId.queryOptions( { taskId }, { - // The server omits refetchInterval once the run is settled — that is - // a stop signal, not missing data. Do not default it back to polling. - refetchInterval: (query) => query.state.data?.refetchInterval ?? false, + // The server only supplies refetchInterval during startup/snapshot + // fast-poll phases; its absence is NOT a settled signal. Keep polling + // until the run actually exits, then stop. + refetchInterval: (query) => { + const data = query.state.data; + if (data?.refetchInterval) return data.refetchInterval; + return data && isExitedRunStatus(data.taskRun?.status) + ? false + : 2_000; + }, }, ), ); diff --git a/apps/web/src/components/sessions/session-surfaces.ts b/apps/web/src/components/sessions/session-surfaces.ts index f00ecf36c..59fc300c9 100644 --- a/apps/web/src/components/sessions/session-surfaces.ts +++ b/apps/web/src/components/sessions/session-surfaces.ts @@ -1,8 +1,12 @@ +import type { TaskSurface } from '@roomote/types'; + +import { getTaskSurfaceLabel } from '@/lib/task-surface-label'; + /** * One registry for every surface a Session can originate from (the - * sessions_source_surface_check constraint's value set). The filter options, - * labels, and brand icons all derive from here so a new surface shows up - * everywhere at once. + * sessions_source_surface_check constraint's value set). Labels come from the + * canonical getTaskSurfaceLabel map so the filter, cards, and analytics can + * never disagree; this module adds the session-only entries and brand icons. */ type SessionSurfaceBrandIcon = @@ -21,19 +25,27 @@ type SurfaceDescriptor = { brandIcon?: SessionSurfaceBrandIcon; }; +const surface = ( + value: TaskSurface, + brandIcon?: SessionSurfaceBrandIcon, +): SurfaceDescriptor => ({ + label: getTaskSurfaceLabel(value) ?? value, + brandIcon, +}); + export const SESSION_SURFACES: Record = { - web: { label: 'Web' }, - api: { label: 'API' }, - slack: { label: 'Slack' }, - teams: { label: 'Teams', brandIcon: 'teams' }, - telegram: { label: 'Telegram', brandIcon: 'telegram' }, - discord: { label: 'Discord', brandIcon: 'discord' }, - linear: { label: 'Linear', brandIcon: 'linear' }, - github: { label: 'GitHub', brandIcon: 'github' }, - gitlab: { label: 'GitLab', brandIcon: 'gitlab' }, - gitea: { label: 'Gitea', brandIcon: 'gitea' }, - ado: { label: 'Azure DevOps', brandIcon: 'ado' }, - bitbucket: { label: 'Bitbucket', brandIcon: 'bitbucket' }, + web: surface('web'), + api: surface('api'), + slack: surface('slack'), + teams: surface('teams', 'teams'), + telegram: surface('telegram', 'telegram'), + discord: surface('discord', 'discord'), + linear: surface('linear', 'linear'), + github: surface('github', 'github'), + gitlab: surface('gitlab', 'gitlab'), + gitea: surface('gitea', 'gitea'), + ado: surface('ado', 'ado'), + bitbucket: surface('bitbucket', 'bitbucket'), system: { label: 'System' }, automation: { label: 'Automation' }, }; diff --git a/apps/web/src/hooks/useMarkSessionRead.ts b/apps/web/src/hooks/useMarkSessionRead.ts index 6ebd6d8d4..dee320ba0 100644 --- a/apps/web/src/hooks/useMarkSessionRead.ts +++ b/apps/web/src/hooks/useMarkSessionRead.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useTRPCClient } from '@/trpc/client'; @@ -11,10 +11,16 @@ import { useTRPCClient } from '@/trpc/client'; */ export function useMarkSessionRead(sessionId: string) { const trpc = useTRPCClient(); + const lastRunAtRef = useRef(0); useEffect(() => { const markRead = () => { if (document.visibilityState !== 'visible') return; + // Returning to a tab fires focus AND visibilitychange back-to-back; + // one mutation is enough. + const now = Date.now(); + if (now - lastRunAtRef.current < 1_000) return; + lastRunAtRef.current = now; void trpc.sessions.markRead.mutate({ sessionId }); }; markRead(); diff --git a/apps/web/src/hooks/useRecentSessions.ts b/apps/web/src/hooks/useRecentSessions.ts index 168880b0c..fff7221d6 100644 --- a/apps/web/src/hooks/useRecentSessions.ts +++ b/apps/web/src/hooks/useRecentSessions.ts @@ -10,6 +10,21 @@ const MAX_RECENT = 20; type RecentEntry = { id: string; visitedAt: number }; +// An earlier build stored plain id strings under the same key; normalize so +// legacy entries neither leak undefined ids nor evade the dedupe filter. +function normalizeEntries(stored: unknown): RecentEntry[] { + if (!Array.isArray(stored)) return []; + return stored + .map((entry): RecentEntry | null => + typeof entry === 'string' + ? { id: entry, visitedAt: 0 } + : entry && typeof (entry as RecentEntry).id === 'string' + ? (entry as RecentEntry) + : null, + ) + .filter((entry): entry is RecentEntry => entry !== null); +} + /** * Tracks recently visited session IDs in localStorage, mirroring * useRecentTasks. Storage is scoped per signed-in user so account switches do @@ -17,15 +32,21 @@ type RecentEntry = { id: string; visitedAt: number }; */ export function useRecentSessions() { const { userId } = useAuthorizedUser(); - const [entries, setEntries] = useLocalStorage( + const [storedEntries, setEntries] = useLocalStorage( `${STORAGE_KEY_PREFIX}:${userId}`, [], ); + const entries = useMemo( + () => normalizeEntries(storedEntries), + [storedEntries], + ); const recordVisit = useCallback( (sessionId: string) => { setEntries((prev) => { - const filtered = prev.filter((entry) => entry.id !== sessionId); + const filtered = normalizeEntries(prev).filter( + (entry) => entry.id !== sessionId, + ); return [{ id: sessionId, visitedAt: Date.now() }, ...filtered].slice( 0, MAX_RECENT, diff --git a/apps/web/src/lib/formatters.ts b/apps/web/src/lib/formatters.ts index bb92891f1..8b6425202 100644 --- a/apps/web/src/lib/formatters.ts +++ b/apps/web/src/lib/formatters.ts @@ -185,5 +185,6 @@ export function formatTokens(tokens: number): string { * label instead of the raw `__all_repositories__` value. */ export function formatRepositoryName(name: string): string { - return name === ALL_REPOSITORIES ? 'All Repositories' : name; + // replaceAll also covers combined values like `repo#123` PR labels. + return name.replaceAll(ALL_REPOSITORIES, 'All Repositories'); } diff --git a/apps/web/src/lib/server/analytics/session-rows.ts b/apps/web/src/lib/server/analytics/session-rows.ts index eba338701..4f2c240c2 100644 --- a/apps/web/src/lib/server/analytics/session-rows.ts +++ b/apps/web/src/lib/server/analytics/session-rows.ts @@ -1,4 +1,4 @@ -import type { TaskSurface } from '@roomote/types'; +import { getSessionStatusLabel, type TaskSurface } from '@roomote/types'; import { and, db, @@ -70,7 +70,7 @@ export async function getSessionAnalyticsRows( value: 1, dimensions: { user: { key: owner, label: owner }, - status: { key: status, label: status.replace('_', ' ') }, + status: { key: status, label: getSessionStatusLabel(status) }, source: createLabelBackedDimensionValue(sourceLabel), ownerKind: { key: row.ownerKind, label: row.ownerKind }, hasExecution: { key: hasExecution, label: hasExecution }, diff --git a/apps/web/src/lib/server/sessions.ts b/apps/web/src/lib/server/sessions.ts index 7328a6182..00bc3bf0f 100644 --- a/apps/web/src/lib/server/sessions.ts +++ b/apps/web/src/lib/server/sessions.ts @@ -40,7 +40,6 @@ type SessionListInput = { status?: 'active' | 'needs_input' | 'blocked' | 'ready'; user?: string | null; repository?: string | null; - environment?: string | null; pullRequest?: string | null; source?: string | null; model?: string | null; @@ -151,20 +150,6 @@ function listConditions(auth: SessionAuth, input: SessionListInput) { 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( @@ -230,6 +215,30 @@ const baseSelection = { updatedAt: sessions.updatedAt, }; +function externalVisibleFastMessageConditions(userId: string) { + return [ + or( + sql`${fastAgentMessages.metadata} ->> 'userId' IS NULL`, + sql`${fastAgentMessages.metadata} ->> 'userId' <> ${userId}`, + ), + // Only events the transcript (and therefore the read cursor) can reach + // may count as unread, or invisible platform events would pin the badge + // forever. + sql`coalesce(${fastAgentMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, + ]; +} + +type HydratedLinkedTask = { + sessionId: string; + taskId: string; + title: string; + workflow: string; + state: string; + repositoryName: string | null; + model: string | null; + activityAt: number; +}; + async function hydrateSessionRows( auth: SessionAuth, rows: Array< @@ -239,6 +248,10 @@ async function hydrateSessionRows( ownerImageUrl: string | null; } >, + options: { + /** Skip the linked-tasks query when the caller already fetched them. */ + preloadedLinkedTasks?: HydratedLinkedTask[]; + } = {}, ) { if (rows.length === 0) return []; const ids = rows.map((row) => row.id); @@ -251,22 +264,23 @@ async function hydrateSessionRows( 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)), - ), + options.preloadedLinkedTasks ?? + 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, @@ -331,14 +345,7 @@ async function hydrateSessionRows( .where( and( inArray(sessions.id, ids), - or( - sql`${fastAgentMessages.metadata} ->> 'userId' IS NULL`, - sql`${fastAgentMessages.metadata} ->> 'userId' <> ${auth.userId}`, - ), - // Only events the transcript (and therefore the read cursor) can - // reach may count as unread, or invisible platform events would pin - // the badge forever. - sql`coalesce(${fastAgentMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, + ...externalVisibleFastMessageConditions(auth.userId), ), ) .groupBy(sessions.id), @@ -566,8 +573,21 @@ export async function getSessionById(auth: SessionAuth, sessionId: string) { (await findAccessibleSession(auth, sessionId)) ?? (await findAccessibleSessionByFastConversationId(auth, sessionId)); if (!session) return null; - const [hydrated] = await hydrateSessionRows(auth, [session]); + // Fetch the task rollups once and feed them into hydration; this endpoint + // is polled, so the duplicate linked-tasks join was pure waste. const sessionTaskDetails = await getSessionTasks(session.id); + const [hydrated] = await hydrateSessionRows(auth, [session], { + preloadedLinkedTasks: sessionTaskDetails.map((task) => ({ + sessionId: session.id, + taskId: task.taskId, + title: task.title, + workflow: task.workflow, + state: task.state, + repositoryName: task.repositoryName, + model: task.model, + activityAt: task.activityAt, + })), + }); const liveStatus = deriveSessionStatus({ conversationResponding: isSessionConversationResponding(session), tasks: sessionTaskDetails.map((task) => ({ @@ -648,17 +668,13 @@ export async function getLatestExternalSessionEvent( const [[latestTask], fastRows] = await Promise.all([ db - .select({ - taskId: tasks.id, - activityAt: sql`max(${tasks.activityAt})::bigint`, - }) + .select({ taskId: tasks.id, activityAt: tasks.activityAt }) .from(sessionTasks) .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) .where( and(eq(sessionTasks.sessionId, sessionId), isNull(tasks.deletedAt)), ) - .groupBy(tasks.id) - .orderBy(desc(sql`max(${tasks.activityAt})`)) + .orderBy(desc(tasks.activityAt)) .limit(1), session.fastConversationId ? db @@ -667,11 +683,7 @@ export async function getLatestExternalSessionEvent( .where( and( eq(fastAgentMessages.conversationId, session.fastConversationId), - or( - sql`${fastAgentMessages.metadata} ->> 'userId' IS NULL`, - sql`${fastAgentMessages.metadata} ->> 'userId' <> ${auth.userId}`, - ), - sql`coalesce(${fastAgentMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, + ...externalVisibleFastMessageConditions(auth.userId), ), ) .orderBy(desc(fastAgentMessages.ts)) diff --git a/apps/web/src/trpc/commands/filters/index.ts b/apps/web/src/trpc/commands/filters/index.ts index d611e0492..4a3de7f69 100644 --- a/apps/web/src/trpc/commands/filters/index.ts +++ b/apps/web/src/trpc/commands/filters/index.ts @@ -17,7 +17,6 @@ import { } from '@roomote/db/server'; import { - ALL_REPOSITORIES, formatExternalActorLabel, type TaskSurface, getTaskModelDisplayName, @@ -29,15 +28,12 @@ import { buildCreatorFilterValue, formatAutomationLabel, } from '@/lib/task-creator-filter'; +import { formatRepositoryName } from '@/lib'; import { getTaskSurfaceLabel } from '@/lib/task-surface-label'; import { getCreatorFilterCondition } from '@/lib/server/tasks'; type FilterOption = { value: string; label: string; subLabel?: string }; -function formatPrRepoName(repo: string): string { - return repo === ALL_REPOSITORIES ? 'All Repositories' : repo; -} - const getTimePeriodCutoff = (timePeriod: number): number => Math.floor(Date.now() / 1000) - timePeriod * 24 * 60 * 60; @@ -278,7 +274,7 @@ export async function getPullRequestsForFilterCommand( .map((r) => { const value = `${r.repository}#${r.prNumber}`; const label = r.prTitle || `#${r.prNumber}`; - const subLabel = `${formatPrRepoName(r.repository)}#${r.prNumber}`; + const subLabel = `${formatRepositoryName(r.repository)}#${r.prNumber}`; return { value, label, subLabel }; }); } diff --git a/apps/web/src/trpc/commands/sessions/index.test.ts b/apps/web/src/trpc/commands/sessions/index.test.ts index a325348a1..ab8d3270c 100644 --- a/apps/web/src/trpc/commands/sessions/index.test.ts +++ b/apps/web/src/trpc/commands/sessions/index.test.ts @@ -1,8 +1,7 @@ import type { UserAuthSuccess } from '@/types'; -const { getSessionByIdMock, resolveTaskAccessMock } = vi.hoisted(() => ({ +const { getSessionByIdMock } = vi.hoisted(() => ({ getSessionByIdMock: vi.fn(), - resolveTaskAccessMock: vi.fn(), })); vi.mock('@/lib/server/sessions', () => ({ @@ -15,9 +14,6 @@ vi.mock('@/lib/server/sessions', () => ({ setSessionPinned: vi.fn(), updateSessionMetadata: vi.fn(), })); -vi.mock('../tasks/by-id', () => ({ - resolveTaskByIdAccessCommand: resolveTaskAccessMock, -})); vi.mock('@roomote/db/server', () => ({ advanceSessionReadCursor: vi.fn(), db: {}, @@ -27,22 +23,24 @@ 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 () => { + it('marks session tasks accessible without per-task access queries', async () => { + // Session-level access is the gate (getSessionById's scope check); + // getSessionTasks only returns live linked tasks, so the old per-task + // access resolution was N+1 dead weight. getSessionByIdMock.mockResolvedValue({ id: 'session-1', tasks: [ { taskId: 'task-1', - title: 'Private execution', - latestRun: { id: 1, error: 'private error', result: {} }, - latestOutput: 'private output', + title: 'Execution', + latestRun: { id: 1, error: null, result: {} }, + latestOutput: 'output', inferenceCostMicroUsd: 123, - artifacts: [{ id: 'artifact-1', path: 'private.txt' }], - pullRequests: [{ id: 'pr-1', url: 'https://example.com/private' }], + artifacts: [{ id: 'artifact-1', path: 'diff.txt' }], + pullRequests: [], }, ], }); - resolveTaskAccessMock.mockResolvedValue({ kind: 'not-found' }); const result = await getSessionByIdCommand( { userId: 'user-1', isAdmin: false } as UserAuthSuccess, @@ -51,12 +49,9 @@ describe('getSessionByIdCommand', () => { expect(result?.tasks[0]).toEqual( expect.objectContaining({ - canAccessDetails: false, - latestRun: null, - latestOutput: null, - inferenceCostMicroUsd: 0, - artifacts: [], - pullRequests: [], + canAccessDetails: true, + latestOutput: 'output', + inferenceCostMicroUsd: 123, }), ); }); diff --git a/apps/web/src/trpc/commands/sessions/index.ts b/apps/web/src/trpc/commands/sessions/index.ts index 06a68d04b..9fb0af4ec 100644 --- a/apps/web/src/trpc/commands/sessions/index.ts +++ b/apps/web/src/trpc/commands/sessions/index.ts @@ -15,7 +15,6 @@ import { 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({ @@ -23,7 +22,6 @@ export const sessionsListInputSchema = z.object({ status: z.enum(SESSION_STATUSES).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(), @@ -73,30 +71,16 @@ export async function getSessionByIdCommand( 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, - }), - ), - ); - + // Session access was already established by getSessionById's scope check, + // and getSessionTasks inner-joins live tasks only — the previous per-task + // access resolution had no additional predicate and cost ~5 queries per + // task on the workspace's polling path. 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: [], - }, - ), + tasks: session.tasks.map((task) => ({ + ...task, + canAccessDetails: true as const, + })), }; } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ce84311aa..1098ab142 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -2843,10 +2843,17 @@ export const appRouter = createRouter({ ), markRead: protectedProcedure .input( - sessionIdInputSchema.extend({ - throughEventAt: z.number().nonnegative().optional(), - throughEventId: z.string().min(1).optional(), - }), + sessionIdInputSchema + .extend({ + throughEventAt: z.number().nonnegative().optional(), + throughEventId: z.string().min(1).optional(), + }) + .refine( + (value) => + (value.throughEventAt === undefined) === + (value.throughEventId === undefined), + 'Pass both cursor fields or neither.', + ), ) .mutation(({ ctx: { auth }, input }) => markSessionReadCommand(auth, input), diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-constants.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-constants.ts index 1d6881fb8..ce9b2952e 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-constants.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-constants.ts @@ -1,4 +1,9 @@ export const FAST_AGENT_MODEL_ROLE = 'orchestration' as const; + +// Generous ceiling on one fast-agent turn: long enough for delegation-heavy +// responses, short enough that a crashed turn self-heals the session status. +// Streaming touch points re-extend it so long turns keep the lease fresh. +export const FAST_RESPONDING_LEASE_MS = 15 * 60 * 1000; export const FAST_AGENT_GITHUB_MCP_PATH = '/api/mcp-routing/github'; export const FAST_AGENT_TASKS_API_PATH = '/api/mcp/tasks'; export const FAST_AGENT_ENVIRONMENTS_API_PATH = '/api/mcp/environments'; 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 f49cfee0c..c976470f3 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 @@ -16,6 +16,7 @@ import { } from '@roomote/db/server'; import { fastAgentConversationSchema } from '@roomote/types'; +import { FAST_RESPONDING_LEASE_MS } from './fast-agent-constants'; import type { FastAgentConversation } from './fast-agent-conversation'; export type FastAgentConversationRecord = { @@ -373,7 +374,19 @@ export const fastAgentConversationRepository: FastAgentConversationRepository = tx, session.id, Math.floor(message.ts / 1000), - { recomputeStatus: false }, + { + recomputeStatus: false, + // An assistant message means the agent is still producing + // output; re-extend the responding lease so long turns do not + // expire it mid-stream. + ...(message.role === 'assistant' + ? { + respondingUntil: new Date( + Date.now() + FAST_RESPONDING_LEASE_MS, + ), + } + : {}), + }, ); const messageUserId = message.metadata?.userId; if (message.role === 'user' && typeof messageUserId === 'string') { 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 06bfdc688..28a3e2466 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 @@ -42,7 +42,10 @@ import { } from '../../utils'; import { resolveRoomoteReleaseVersion } from '../../release-version'; import { getAvailableEnvironments, type RoutableEnvironment } from '../router'; -import { FAST_AGENT_MODEL_ROLE } from './fast-agent-constants'; +import { + FAST_AGENT_MODEL_ROLE, + FAST_RESPONDING_LEASE_MS, +} from './fast-agent-constants'; import { buildFastAgentSystemPrompt } from './fast-agent-prompt'; import { appendFastAgentVisibleMessages, @@ -140,10 +143,6 @@ 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; -// Generous ceiling on one fast-agent turn: long enough for delegation-heavy -// responses, short enough that a crashed turn self-heals the session status. -const FAST_RESPONDING_LEASE_MS = 15 * 60 * 1000; - async function setFastSessionResponding( fastConversationId: string, responding: boolean, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts index cabd7b6be..69a4c7ab2 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts @@ -5,9 +5,9 @@ import { eq, fastAgentConversations, fastAgentMessages, + inArray, isNull, lt, - or, sessions, sql, } from '@roomote/db/server'; @@ -121,6 +121,16 @@ export async function refreshFastAgentSessionTitle({ } await db.transaction(async (tx) => { + // Re-read the conversation title under a row lock: the pre-generation + // snapshot may be stale by now, and the session guard below must match + // the title the session was actually seeded/synced from. + const [current] = await tx + .select({ title: fastAgentConversations.title }) + .from(fastAgentConversations) + .where(eq(fastAgentConversations.id, sessionId)) + .for('update'); + if (!current) return; + const [updatedConversation] = await tx .update(fastAgentConversations) .set({ title, llmTitleCheckpoint: checkpoint }) @@ -136,17 +146,23 @@ export async function refreshFastAgentSessionTitle({ // Keep the unified Session's title in step with the generated // conversation title, but never clobber a manual Session rename: only - // overwrite the creation placeholder or a previous generated title. + // overwrite the creation placeholder or the previous conversation + // title (session titles are seeded trimmed, so match both forms). + const previousTitleCandidates = new Set(['New session']); + if (current.title) { + previousTitleCandidates.add(current.title); + const trimmed = current.title.trim(); + if (trimmed) { + previousTitleCandidates.add(trimmed); + } + } await tx .update(sessions) .set({ title, updatedAt: new Date() }) .where( and( eq(sessions.fastConversationId, sessionId), - or( - eq(sessions.title, 'New session'), - eq(sessions.title, conversation.title ?? ''), - ), + inArray(sessions.title, [...previousTitleCandidates]), ), ); }); diff --git a/packages/db/src/lib/__tests__/sessions.test.ts b/packages/db/src/lib/__tests__/sessions.test.ts index 4a5b6384f..7f164be4d 100644 --- a/packages/db/src/lib/__tests__/sessions.test.ts +++ b/packages/db/src/lib/__tests__/sessions.test.ts @@ -390,6 +390,31 @@ describe('session helpers', () => { ).toHaveLength(2); }); + it('ignores an unknown Fast conversation id instead of aborting', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const task = await taskFactory.create({ initiatorUserId: user.id }); + createdTaskIds.push(task.id); + + const session = await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: task.id, + fastConversationId: crypto.randomUUID(), + origin: 'fast_delegation', + }), + ); + + expect(session).not.toBeNull(); + if (session) createdSessionIds.push(session.id); + expect(session?.fastConversationId).toBeNull(); + expect( + await db + .select() + .from(sessionTasks) + .where(eq(sessionTasks.taskId, task.id)), + ).toHaveLength(1); + }); + it('creates one Session when a Fast conversation is created repeatedly', async () => { const user = await userFactory.create(); createdUserIds.push(user.id); diff --git a/packages/db/src/lib/sessions.ts b/packages/db/src/lib/sessions.ts index ac3af2fb0..cfd7e5007 100644 --- a/packages/db/src/lib/sessions.ts +++ b/packages/db/src/lib/sessions.ts @@ -269,8 +269,23 @@ export async function ensureSessionForTask( return existing; } - let session = input.fastConversationId - ? await getSessionForFastConversation(tx, input.fastConversationId) + // Callers may pass a raw payload conversation id that was never persisted + // (or was renamed away). Verify it exists before referencing it so the + // sessions insert cannot hit the FK and abort the caller's transaction. + let fastConversationId = input.fastConversationId ?? null; + if (fastConversationId) { + const [conversation] = await tx + .select({ id: fastAgentConversations.id }) + .from(fastAgentConversations) + .where(eq(fastAgentConversations.id, fastConversationId)) + .limit(1); + if (!conversation) { + fastConversationId = null; + } + } + + let session = fastConversationId + ? await getSessionForFastConversation(tx, fastConversationId) : null; let createdCandidate = false; @@ -301,7 +316,7 @@ export async function ensureSessionForTask( ...owner, sourceSurface: task.surface, sourceTrigger: task.trigger, - fastConversationId: input.fastConversationId ?? null, + fastConversationId, visibility: task.visibility, activityAt: task.activityAt, cachedStatus: deriveSessionStatus({ @@ -320,8 +335,8 @@ export async function ensureSessionForTask( session = inserted ?? - (input.fastConversationId - ? await getSessionForFastConversation(tx, input.fastConversationId) + (fastConversationId + ? await getSessionForFastConversation(tx, fastConversationId) : null); createdCandidate = inserted !== undefined; } diff --git a/packages/slack/src/fast-agent-live-task-launcher.ts b/packages/slack/src/fast-agent-live-task-launcher.ts index ca0145490..7a767d025 100644 --- a/packages/slack/src/fast-agent-live-task-launcher.ts +++ b/packages/slack/src/fast-agent-live-task-launcher.ts @@ -23,7 +23,7 @@ type SlackLiveTaskCardNotifier = Pick< 'postMessage' | 'postMessageDetailed' | 'updateMessage' >; -export const PREPARING_WORKSPACE_TITLE = 'Preparing workspace…'; +const PREPARING_WORKSPACE_TITLE = 'Preparing workspace…'; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); From 73a00cf1a28fe7c19612fceae2277f669fc58145 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:41:15 -0400 Subject: [PATCH 37/39] Fix client suite breakage from the tool-presentation changes - tool-presentation.ts imports sanitizeSandboxPathString directly instead of via the @/lib barrel, which dragged icon-bearing modules into tests that mock @/components/system - Update the grouped-tool anchors test for the compact layout: anchors are standalone hidden divs now, with no collapsed ToolContent container to assert against --- .../AcpGroupedToolMessage.anchors.client.test.tsx | 13 ++++++++----- .../task/[taskId]/messages/acp/tool-presentation.ts | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx index 2edeaacfa..f30360a05 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx @@ -142,16 +142,19 @@ function buildGroup(): GroupedToolCallRenderBlock { describe('AcpGroupedToolMessage anchors', () => { it('keeps per-item anchors mounted even when tool content is collapsed', () => { + // The compact grouped layout renders anchors as standalone hidden divs + // (no collapsed ToolContent container anymore); scroll targets must stay + // in the DOM while per-item detail stays unmounted. render(); - const collapsedContent = screen.getByTestId('collapsed-tool-content'); - expect(document.getElementById('msg-101')).toBeTruthy(); expect(document.getElementById('msg-102')).toBeTruthy(); - expect(collapsedContent.querySelector('#msg-101')).toBeNull(); - expect(collapsedContent.querySelector('#msg-102')).toBeNull(); + expect(document.getElementById('msg-101')).toHaveAttribute( + 'aria-hidden', + 'true', + ); - // Subheadings live inside ToolContent and should not be mounted in collapsed mode. + // Subheadings only mount with expanded tool detail. expect(screen.queryByText('file_b.txt')).toBeNull(); expect(screen.queryByText('file_c.txt')).toBeNull(); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts index 554037f6b..021532e92 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -4,7 +4,9 @@ import { type AcpToolResultPayload, } from '@roomote/types'; -import { sanitizeSandboxPathString } from '@/lib'; +// Direct import: the @/lib barrel drags icon-bearing modules into any test +// that mocks @/components/system. +import { sanitizeSandboxPathString } from '@/lib/sandbox-paths'; export type ToolPresentationCategory = | 'execute' From 41f3ba71db8c596f93d0667a584972bb790f2f28 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:47:34 -0400 Subject: [PATCH 38/39] Keep failed orphan adoptions inside the reconcile scan window Per-item error isolation previously advanced the scan watermark past transiently-failed rows; after the one-hour cutoff they were never rescanned. The watermark now lives in its own state row and advances only when every orphan adoption in the pass succeeded, so failures stay in the window and converge once the outage clears. A permanently failing row degrades scan bounding (logged every run) rather than stranding data. --- .../__tests__/sessions-reconcile.test.ts | 13 ++++ .../src/scheduled-jobs/sessions-reconcile.ts | 64 +++++++++++++++---- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts index c8acbd77b..3c469ddf3 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -138,9 +138,22 @@ describe('sessionsReconcileJob', () => { .where(eq(sessions.fastConversationId, poisoned!.id)), ).resolves.toHaveLength(0); + // A failed adoption must NOT advance the reconcile watermark, so the + // failed row stays inside the next run's scan window instead of being + // stranded past the cutoff once the failure clears. + const watermarkBefore = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); await db .delete(fastAgentConversations) .where(eq(fastAgentConversations.id, poisoned!.id)); + await sessionsReconcileJob(); + const watermarkAfter = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + expect(watermarkAfter?.cursorCreatedAt?.getTime() ?? 0).toBeGreaterThan( + watermarkBefore?.cursorCreatedAt?.getTime() ?? 0, + ); }); it('heals sessions wedged active on an expired responding lease', async () => { diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts index 1a26b49a6..b4190ac88 100644 --- a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -21,6 +21,15 @@ import { } from '@roomote/db/server'; const LOG_PREFIX = '[sessions]'; const BACKFILL_KEY = 'unified-sessions-v1'; +/** + * Steady-state reconcile watermark, stored as a second state row: its + * cursorCreatedAt marks the scan-start time of the last orphan pass that + * completed with ZERO failures. Advancing only on clean passes means a + * transient outage keeps failed rows inside the scan window until they + * actually converge, instead of stranding them past the cutoff forever. + */ +const RECONCILE_KEY = 'unified-sessions-reconcile-v1'; +const RECONCILE_CURSOR_ID = 'watermark'; const BATCH_SIZE = 100; /** Slack subtracted from the last-run watermark when bounding orphan scans. */ const ORPHAN_SCAN_SLACK_MS = 60 * 60 * 1000; @@ -186,13 +195,16 @@ async function backfillParticipants(): Promise { console.info(`${LOG_PREFIX} backfill participants complete`); } -async function reconcileRecentSessions(lastRunAt: Date | null): Promise { - // After backfill completion, bound the steady-state orphan scans to rows - // created since the previous reconcile (with slack) so they stop scanning - // entire tables every run. A null watermark means scan unbounded once. - const cutoff = lastRunAt - ? new Date(lastRunAt.getTime() - ORPHAN_SCAN_SLACK_MS) +async function reconcileRecentSessions(watermark: Date | null): Promise { + // Bound the steady-state orphan scans to rows created since the last + // fully-successful pass (with slack) so they stop scanning entire tables + // every run. A null watermark (first run, or no clean pass yet) scans + // unbounded. + const cutoff = watermark + ? new Date(watermark.getTime() - ORPHAN_SCAN_SLACK_MS) : null; + const scanStartedAt = new Date(); + let orphanFailures = 0; // Fast conversations without a session row (e.g. created before this // release finished its backfill) are adopted here so the unified list @@ -219,6 +231,7 @@ async function reconcileRecentSessions(lastRunAt: Date | null): Promise { ensureSessionForFastConversation(tx, conversation.id), ); } catch (error) { + orphanFailures += 1; console.error( `${LOG_PREFIX} reconcile failed for fast conversation ${conversation.id}`, error, @@ -247,6 +260,7 @@ async function reconcileRecentSessions(lastRunAt: Date | null): Promise { ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), ); } catch (error) { + orphanFailures += 1; console.error( `${LOG_PREFIX} reconcile failed for task ${task.id}`, error, @@ -299,11 +313,34 @@ async function reconcileRecentSessions(lastRunAt: Date | null): Promise { } } - // Advance only the watermark; updateState would clobber completedAt. - await db - .update(sessionBackfillState) - .set({ lastRunAt: new Date(), updatedAt: new Date() }) - .where(eq(sessionBackfillState.key, BACKFILL_KEY)); + // Advance the watermark only when every orphan adoption succeeded, so + // transiently-failed rows stay inside the next scan window and eventually + // converge. Failures in the touch/heal loops don't affect orphan scanning. + if (orphanFailures === 0) { + await db + .insert(sessionBackfillState) + .values({ + key: RECONCILE_KEY, + phase: 'participants', + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + completedAt: null, + lastRunAt: scanStartedAt, + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + lastRunAt: scanStartedAt, + updatedAt: new Date(), + }, + }); + } else { + console.warn( + `${LOG_PREFIX} keeping the reconcile watermark: ${orphanFailures} orphan adoption(s) failed`, + ); + } console.info(`${LOG_PREFIX} reconciliation`, { orphanFastConversations: orphanConversations.length, @@ -318,7 +355,10 @@ export async function sessionsReconcileJob(): Promise { where: eq(sessionBackfillState.key, BACKFILL_KEY), }); if (state?.completedAt) { - await reconcileRecentSessions(state.lastRunAt); + const reconcileState = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, RECONCILE_KEY), + }); + await reconcileRecentSessions(reconcileState?.cursorCreatedAt ?? null); return; } From f69ba82fea5b54f451b2f71bc446726bf60217ca Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:56:20 -0400 Subject: [PATCH 39/39] Do not advance the reconcile watermark past a full orphan batch A pass that fills the 100-row batch may leave older orphans beyond the LIMIT; advancing the watermark then strands them outside the scan window. The watermark now moves only when a pass had zero adoption failures and neither scan returned a full batch, so an over-batch backlog drains across successive runs. Covered by a 101-orphan test. --- .../__tests__/sessions-reconcile.test.ts | 31 +++++++++++++++++++ .../src/scheduled-jobs/sessions-reconcile.ts | 15 ++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts index 3c469ddf3..040a492fb 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -2,6 +2,7 @@ import { db, eq, fastAgentConversations, + inArray, sessionBackfillState, sessionFactory, sessionTasks, @@ -156,6 +157,36 @@ describe('sessionsReconcileJob', () => { ); }); + it('drains an over-batch orphan backlog across runs without stranding rows', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + // 101 orphans: one full batch plus one. A full batch must NOT advance + // the watermark, so the next run still sees (and adopts) the remainder. + const user = await userFactory.create(); + const rows = await db + .insert(fastAgentConversations) + .values( + Array.from({ length: 101 }, () => ({ + userId: user.id, + surface: 'web' as const, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + })), + ) + .returning({ id: fastAgentConversations.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const ids = rows.map((row) => row.id); + const adopted = await db + .select({ id: sessions.fastConversationId }) + .from(sessions) + .where(inArray(sessions.fastConversationId, ids)); + expect(adopted).toHaveLength(101); + }); + it('heals sessions wedged active on an expired responding lease', async () => { await sessionsReconcileJob(); await sessionsReconcileJob(); diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts index b4190ac88..52a2ec63a 100644 --- a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -313,10 +313,15 @@ async function reconcileRecentSessions(watermark: Date | null): Promise { } } - // Advance the watermark only when every orphan adoption succeeded, so - // transiently-failed rows stay inside the next scan window and eventually - // converge. Failures in the touch/heal loops don't affect orphan scanning. - if (orphanFailures === 0) { + // Advance the watermark only when this pass definitely drained the + // backlog: zero adoption failures AND neither scan returned a full batch + // (a full batch means older rows may remain beyond the LIMIT). Otherwise + // the next run rescans the same window until it converges. Failures in + // the touch/heal loops don't affect orphan scanning. + const sawFullBatch = + orphanConversations.length === BATCH_SIZE || + orphanTasks.length === BATCH_SIZE; + if (orphanFailures === 0 && !sawFullBatch) { await db .insert(sessionBackfillState) .values({ @@ -338,7 +343,7 @@ async function reconcileRecentSessions(watermark: Date | null): Promise { }); } else { console.warn( - `${LOG_PREFIX} keeping the reconcile watermark: ${orphanFailures} orphan adoption(s) failed`, + `${LOG_PREFIX} keeping the reconcile watermark: ${orphanFailures} orphan adoption(s) failed, fullBatch=${sawFullBatch}`, ); }