diff --git a/packages/api/src/data/repositories/studios.repository.ts b/packages/api/src/data/repositories/studios.repository.ts index 2d993b0e..e8433fab 100644 --- a/packages/api/src/data/repositories/studios.repository.ts +++ b/packages/api/src/data/repositories/studios.repository.ts @@ -29,6 +29,7 @@ export interface Studio { workType: string | null; slug: string | null; roleTemplate: string | null; + defaultProjectId: string | null; status: StudioStatus; metadata: Json; createdAt: string; @@ -49,6 +50,7 @@ export interface CreateStudioInput { purpose?: string; workType?: WorkType; roleTemplate?: string; + defaultProjectId?: string | null; metadata?: Json; } @@ -60,6 +62,7 @@ export interface UpdateStudioInput { roleTemplate?: string | null; worktreePath?: string; slug?: string | null; + defaultProjectId?: string | null; metadata?: Json; archivedAt?: string; cleanedAt?: string; @@ -95,6 +98,7 @@ export class StudiosRepository { workType: (row.work_type as string) || null, slug: (row.slug as string) || null, roleTemplate: (row.role_template as string) || null, + defaultProjectId: (row.default_project_id as string) || null, status: row.status as StudioStatus, metadata: (row.metadata as Json) || {}, createdAt: row.created_at as string, @@ -121,6 +125,7 @@ export class StudiosRepository { purpose: input.purpose, work_type: input.workType, role_template: input.roleTemplate, + default_project_id: input.defaultProjectId ?? null, slug: deriveStudioSlug(input.worktreePath), status: 'active', metadata: input.metadata || {}, @@ -276,6 +281,8 @@ export class StudiosRepository { if (input.roleTemplate !== undefined) updateData.role_template = input.roleTemplate; if (input.worktreePath !== undefined) updateData.worktree_path = input.worktreePath; if (input.slug !== undefined) updateData.slug = input.slug; + if (input.defaultProjectId !== undefined) + updateData.default_project_id = input.defaultProjectId; if (input.metadata !== undefined) updateData.metadata = input.metadata; if (input.archivedAt !== undefined) updateData.archived_at = input.archivedAt; if (input.cleanedAt !== undefined) updateData.cleaned_at = input.cleanedAt; diff --git a/packages/api/src/data/supabase/types.ts b/packages/api/src/data/supabase/types.ts index 1c54a5ea..1ee79592 100644 --- a/packages/api/src/data/supabase/types.ts +++ b/packages/api/src/data/supabase/types.ts @@ -3054,6 +3054,7 @@ export type Database = { branch: string; cleaned_at: string | null; created_at: string | null; + default_project_id: string | null; id: string; metadata: Json | null; permissions: Json; @@ -3078,6 +3079,7 @@ export type Database = { branch: string; cleaned_at?: string | null; created_at?: string | null; + default_project_id?: string | null; id?: string; metadata?: Json | null; permissions?: Json; @@ -3102,6 +3104,7 @@ export type Database = { branch?: string; cleaned_at?: string | null; created_at?: string | null; + default_project_id?: string | null; id?: string; metadata?: Json | null; permissions?: Json; diff --git a/packages/api/src/mcp/tools/studio-handlers.ts b/packages/api/src/mcp/tools/studio-handlers.ts index b7142067..9a65092c 100644 --- a/packages/api/src/mcp/tools/studio-handlers.ts +++ b/packages/api/src/mcp/tools/studio-handlers.ts @@ -69,6 +69,11 @@ const createStudioSchema = userIdentifierBaseSchema.extend({ .string() .optional() .describe('Role template name used when creating the studio (e.g., "reviewer", "builder")'), + defaultProjectId: z + .string() + .uuid() + .optional() + .describe('Default project ID for task groups created in this studio'), skipGitOperations: z .boolean() .optional() @@ -113,6 +118,12 @@ const updateStudioSchema = userIdentifierBaseSchema.extend({ .boolean() .optional() .describe('If true, unlink the current session and set status to idle'), + defaultProjectId: z + .string() + .uuid() + .nullable() + .optional() + .describe('Default project ID for task groups created in this studio. Set to null to clear.'), routePatterns: z .array(z.string().max(200)) .optional() @@ -191,9 +202,17 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos baseBranch = 'main', sessionId, roleTemplate, + defaultProjectId, skipGitOperations = false, } = parsed; + if (defaultProjectId) { + const project = await dataComposer.repositories.projects.findById(defaultProjectId); + if (!project || project.user_id !== resolved.user.id) { + return errorResponse('defaultProjectId not found or does not belong to this user'); + } + } + // Resolve to the main worktree root (handles case where repoRoot is a linked worktree) const mainRoot = resolveMainWorktree(repoRoot); @@ -251,6 +270,7 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos purpose, workType, roleTemplate, + defaultProjectId, }); } catch (dbError) { // If DB insert fails but git succeeded, attempt cleanup @@ -293,6 +313,7 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos purpose: studio.purpose, workType: studio.workType, roleTemplate: studio.roleTemplate, + defaultProjectId: studio.defaultProjectId, status: studio.status, sessionId: studio.sessionId, createdAt: studio.createdAt, @@ -339,6 +360,7 @@ export async function handleListStudios(args: unknown, dataComposer: DataCompose status: w.status, workType: w.workType, roleTemplate: w.roleTemplate, + defaultProjectId: w.defaultProjectId, hasLinkedSession: !!w.sessionId, createdAt: w.createdAt, })), @@ -381,6 +403,7 @@ export async function handleGetStudio(args: unknown, dataComposer: DataComposer) purpose: studio.purpose, workType: studio.workType, roleTemplate: studio.roleTemplate, + defaultProjectId: studio.defaultProjectId, status: studio.status, sessionId: studio.sessionId, metadata: studio.metadata, @@ -394,7 +417,7 @@ export async function handleGetStudio(args: unknown, dataComposer: DataComposer) export async function handleUpdateStudio(args: unknown, dataComposer: DataComposer) { const parsed = updateStudioSchema.parse(args); - await resolveUserOrThrow(parsed, dataComposer); + const resolved = await resolveUserOrThrow(parsed, dataComposer); const { studioId, @@ -406,15 +429,26 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos slug, sessionId, unlinkSession, + defaultProjectId, routePatterns, } = parsed; const studiosRepo = dataComposer.repositories.studios; - // Verify studio exists + // Verify studio exists and belongs to this user const existing = await studiosRepo.findById(studioId); if (!existing) { return errorResponse(`Studio not found: ${studioId}`); } + if (existing.userId !== resolved.user.id) { + return errorResponse(`Studio not found: ${studioId}`); + } + + if (typeof defaultProjectId === 'string') { + const project = await dataComposer.repositories.projects.findById(defaultProjectId); + if (!project || project.user_id !== resolved.user.id) { + return errorResponse('defaultProjectId not found or does not belong to this user'); + } + } let updated; @@ -439,6 +473,9 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos if (slug !== undefined) { updateObj.slug = slug; } + if (defaultProjectId !== undefined) { + updateObj.defaultProjectId = defaultProjectId; + } if (routePatterns !== undefined) { updateObj.routePatterns = routePatterns; } @@ -458,6 +495,7 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos worktreePath: updated.worktreePath, purpose: updated.purpose, roleTemplate: updated.roleTemplate, + defaultProjectId: updated.defaultProjectId, status: updated.status, sessionId: updated.sessionId, updatedAt: updated.updatedAt, @@ -599,6 +637,7 @@ export async function handleAdoptStudio(args: unknown, dataComposer: DataCompose worktreePath: updated.worktreePath, purpose: updated.purpose, roleTemplate: updated.roleTemplate, + defaultProjectId: updated.defaultProjectId, status: updated.status, sessionId: updated.sessionId, updatedAt: updated.updatedAt, diff --git a/packages/api/src/mcp/tools/task-handlers.test.ts b/packages/api/src/mcp/tools/task-handlers.test.ts index 88e91c31..4b44a5db 100644 --- a/packages/api/src/mcp/tools/task-handlers.test.ts +++ b/packages/api/src/mcp/tools/task-handlers.test.ts @@ -91,6 +91,7 @@ function createMockDataComposer() { const mockProjectsRepo = { findById: vi.fn(), + findByUserAndName: vi.fn(), }; const mockMemoryRepo = { @@ -101,6 +102,10 @@ function createMockDataComposer() { logActivity: vi.fn().mockResolvedValue({ id: 'activity-1' }), }; + const mockStudiosRepo = { + findById: vi.fn(), + }; + // Minimal supabase client stub for identity/agent_identities lookups // and task_group_comments inserts/queries. const identityChain = { @@ -137,6 +142,7 @@ function createMockDataComposer() { tasks: mockTasksRepo, taskGroups: mockTaskGroupsRepo, projects: mockProjectsRepo, + studios: mockStudiosRepo, memory: mockMemoryRepo, activityStream: mockActivityStreamRepo, }, @@ -1325,6 +1331,166 @@ describe('handleCreateTaskGroup', () => { expect(data.error).toBe('User not found'); expect(dc.repositories.taskGroups.create).not.toHaveBeenCalled(); }); + + it('inherits defaultProjectId from studio when no projectId provided', async () => { + const { getRequestContext } = await import('../../utils/request-context'); + (getRequestContext as any).mockReturnValueOnce({ + studioId: 'studio-1', + }); + + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-1', + defaultProjectId: 'proj-default', + }); + dc.repositories.projects.findById.mockResolvedValue({ + id: 'proj-default', + user_id: 'user-123', + name: 'Inkwell', + }); + dc.repositories.taskGroups.create.mockResolvedValue({ + id: 'grp-2', + user_id: 'user-123', + sb_id: null, + project_id: 'proj-default', + title: 'Inherited project group', + status: 'active', + priority: 'normal', + tags: [], + metadata: {}, + autonomous: false, + max_sessions: null, + sessions_used: 0, + context_summary: null, + next_run_after: null, + output_target: null, + output_status: null, + thread_key: null, + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:00:00Z', + }); + + const response = await handleCreateTaskGroup( + { + userId: 'user-123', + title: 'Inherited project group', + priority: 'normal', + status: 'active', + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.taskGroups.create).toHaveBeenCalledWith( + expect.objectContaining({ project_id: 'proj-default' }) + ); + }); + + it('skips studio default project if it belongs to a different user', async () => { + const { getRequestContext } = await import('../../utils/request-context'); + (getRequestContext as any).mockReturnValueOnce({ + studioId: 'studio-1', + }); + + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-1', + defaultProjectId: 'proj-foreign', + }); + dc.repositories.projects.findById.mockResolvedValue({ + id: 'proj-foreign', + user_id: 'other-user-999', + name: 'Foreign Project', + }); + dc.repositories.taskGroups.create.mockResolvedValue({ + id: 'grp-3', + user_id: 'user-123', + sb_id: null, + project_id: null, + title: 'No project', + status: 'active', + priority: 'normal', + tags: [], + metadata: {}, + autonomous: false, + max_sessions: null, + sessions_used: 0, + context_summary: null, + next_run_after: null, + output_target: null, + output_status: null, + thread_key: null, + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:00:00Z', + }); + + const response = await handleCreateTaskGroup( + { + userId: 'user-123', + title: 'No project', + priority: 'normal', + status: 'active', + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.taskGroups.create).toHaveBeenCalledWith( + expect.objectContaining({ project_id: null }) + ); + }); + + it('explicit projectId takes precedence over studio default', async () => { + const { getRequestContext } = await import('../../utils/request-context'); + (getRequestContext as any).mockReturnValueOnce({ + studioId: 'studio-1', + }); + + dc.repositories.projects.findById.mockResolvedValue({ + id: '00000000-0000-0000-0000-000000000002', + user_id: 'user-123', + name: 'Explicit Project', + }); + dc.repositories.taskGroups.create.mockResolvedValue({ + id: 'grp-4', + user_id: 'user-123', + sb_id: null, + project_id: '00000000-0000-0000-0000-000000000002', + title: 'Explicit project group', + status: 'active', + priority: 'normal', + tags: [], + metadata: {}, + autonomous: false, + max_sessions: null, + sessions_used: 0, + context_summary: null, + next_run_after: null, + output_target: null, + output_status: null, + thread_key: null, + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:00:00Z', + }); + + const response = await handleCreateTaskGroup( + { + userId: 'user-123', + title: 'Explicit project group', + projectId: '00000000-0000-0000-0000-000000000002', + priority: 'normal', + status: 'active', + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.studios.findById).not.toHaveBeenCalled(); + expect(dc.repositories.taskGroups.create).toHaveBeenCalledWith( + expect.objectContaining({ project_id: '00000000-0000-0000-0000-000000000002' }) + ); + }); }); // ===================================================== @@ -2037,6 +2203,55 @@ describe('handleListTaskGroups', () => { expect(data.error).toBe('User not found'); expect(dc.repositories.taskGroups.listByUser).not.toHaveBeenCalled(); }); + + it('resolves projectName to projectId for filtering', async () => { + dc.repositories.projects.findByUserAndName.mockResolvedValue({ + id: 'proj-by-name', + user_id: 'user-123', + name: 'Inkwell', + }); + dc.repositories.taskGroups.listByUser.mockResolvedValue([]); + dc.repositories.taskGroups.taskCountsByGroup.mockResolvedValue({}); + + const response = await handleListTaskGroups( + { + userId: 'user-123', + projectName: 'Inkwell', + autonomousOnly: false, + includeTaskCounts: true, + limit: 200, + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.projects.findByUserAndName).toHaveBeenCalledWith('user-123', 'Inkwell'); + expect(dc.repositories.taskGroups.listByUser).toHaveBeenCalledWith( + 'user-123', + expect.objectContaining({ projectId: 'proj-by-name' }) + ); + }); + + it('returns error when projectName does not match any project', async () => { + dc.repositories.projects.findByUserAndName.mockResolvedValue(null); + + const response = await handleListTaskGroups( + { + userId: 'user-123', + projectName: 'Nonexistent', + autonomousOnly: false, + includeTaskCounts: true, + limit: 200, + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Project not found: Nonexistent'); + expect(dc.repositories.taskGroups.listByUser).not.toHaveBeenCalled(); + }); }); // ===================================================== diff --git a/packages/api/src/mcp/tools/task-handlers.ts b/packages/api/src/mcp/tools/task-handlers.ts index f4afec5d..4868b73e 100644 --- a/packages/api/src/mcp/tools/task-handlers.ts +++ b/packages/api/src/mcp/tools/task-handlers.ts @@ -1177,6 +1177,8 @@ export async function handleCreateTaskGroup( return mcpResponse({ success: false, error: 'User not found' }, true); } + let effectiveProjectId = args.projectId ?? null; + if (args.projectId) { const project = await dataComposer.repositories.projects.findById(args.projectId); if (!project) { @@ -1189,6 +1191,18 @@ export async function handleCreateTaskGroup( const agentId = getEffectiveAgentId(args.agentId); const reqCtx = getRequestContext(); + + if (!effectiveProjectId && reqCtx?.studioId) { + const studio = await dataComposer.repositories.studios.findById(reqCtx.studioId); + if (studio?.defaultProjectId) { + const defaultProject = await dataComposer.repositories.projects.findById( + studio.defaultProjectId + ); + if (defaultProject && defaultProject.user_id === resolved.user.id) { + effectiveProjectId = studio.defaultProjectId; + } + } + } const sbId = await resolveIdentityIdForAgent( dataComposer, resolved.user.id, @@ -1199,7 +1213,7 @@ export async function handleCreateTaskGroup( const group = await dataComposer.repositories.taskGroups.create({ user_id: resolved.user.id, sb_id: sbId, - project_id: args.projectId ?? null, + project_id: effectiveProjectId, title: args.title, description: args.description, status: args.status, @@ -1424,7 +1438,11 @@ export const listTaskGroupsSchema = z.object({ .describe( 'Filter by one or more statuses: active, paused, completed, cancelled. Omit or pass empty array to include all statuses.' ), - projectId: z.string().uuid().optional().describe('Filter by project'), + projectId: z.string().uuid().optional().describe('Filter by project UUID'), + projectName: z + .string() + .optional() + .describe('Filter by project name (exact match). Alternative to projectId.'), sbId: z.string().uuid().optional().describe('Filter by agent identity UUID'), autonomousOnly: z.boolean().optional().default(false).describe('Only autonomous groups'), includeTaskCounts: z @@ -1448,9 +1466,24 @@ export async function handleListTaskGroups( // Empty array is treated the same as omitted: include all statuses. const statuses = args.statuses && args.statuses.length > 0 ? args.statuses : undefined; + let projectId = args.projectId; + if (!projectId && args.projectName) { + const project = await dataComposer.repositories.projects.findByUserAndName( + resolved.user.id, + args.projectName + ); + if (!project) { + return mcpResponse( + { success: false, error: `Project not found: ${args.projectName}` }, + true + ); + } + projectId = project.id; + } + const groups = await dataComposer.repositories.taskGroups.listByUser(resolved.user.id, { status: statuses, - projectId: args.projectId, + projectId, sbId: args.sbId, autonomousOnly: args.autonomousOnly, limit: args.limit, diff --git a/supabase/migrations/20260616221341_add_default_project_id_to_studios.sql b/supabase/migrations/20260616221341_add_default_project_id_to_studios.sql new file mode 100644 index 00000000..f4fc2cde --- /dev/null +++ b/supabase/migrations/20260616221341_add_default_project_id_to_studios.sql @@ -0,0 +1,9 @@ +-- Add default_project_id to studios +-- When an SB creates a task group from a studio, it inherits this project +-- unless explicitly overridden or set to null. +ALTER TABLE studios + ADD COLUMN default_project_id uuid REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX idx_studios_default_project_id + ON studios (default_project_id) + WHERE default_project_id IS NOT NULL;