From 80fac49f00d65abd50ade0f94800d65f90cda1ac Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 19:09:02 -0800 Subject: [PATCH 01/10] feat: introduce studio-first naming for session/worktree flows --- packages/api/src/data/models/memory.ts | 8 ++ .../repositories/memory-repository.test.ts | 44 +++++++- .../data/repositories/memory-repository.ts | 32 +++--- .../repositories/workspaces.repository.ts | 21 ++++ packages/api/src/mcp/tools/index.ts | 37 ++++--- .../api/src/mcp/tools/memory-handlers.test.ts | 87 ++++++++++----- packages/api/src/mcp/tools/memory-handlers.ts | 104 ++++++++++++++---- .../api/src/mcp/tools/workspace-handlers.ts | 11 ++ packages/cli/README.md | 28 +++-- packages/cli/src/cli.ts | 2 +- packages/cli/src/commands/workspace.ts | 54 ++++----- 11 files changed, 310 insertions(+), 118 deletions(-) diff --git a/packages/api/src/data/models/memory.ts b/packages/api/src/data/models/memory.ts index cd6b4d53..3637ae24 100644 --- a/packages/api/src/data/models/memory.ts +++ b/packages/api/src/data/models/memory.ts @@ -65,6 +65,10 @@ export interface Session { id: string; userId: string; agentId?: string; + studioId?: string; + /** + * @deprecated Use studioId. Kept for backward compatibility during migration. + */ workspaceId?: string; currentPhase?: string; startedAt: Date; @@ -76,6 +80,10 @@ export interface Session { export interface SessionCreateInput { userId: string; agentId?: string; + studioId?: string; + /** + * @deprecated Use studioId. Kept for backward compatibility during migration. + */ workspaceId?: string; metadata?: Record; } diff --git a/packages/api/src/data/repositories/memory-repository.test.ts b/packages/api/src/data/repositories/memory-repository.test.ts index 8146507d..31f05bc4 100644 --- a/packages/api/src/data/repositories/memory-repository.test.ts +++ b/packages/api/src/data/repositories/memory-repository.test.ts @@ -276,11 +276,12 @@ describe('MemoryRepository', () => { expect(result.id).toBe('session-123'); expect(result.userId).toBe('user-456'); expect(result.agentId).toBe('claude-code'); + expect(result.studioId).toBeUndefined(); expect(result.workspaceId).toBeUndefined(); expect(result.endedAt).toBeUndefined(); }); - it('should include workspace_id in insert when workspaceId is provided', async () => { + it('should include workspace_id in insert when studioId is provided', async () => { const mockSessionRow = { id: 'session-ws', user_id: 'user-456', @@ -297,9 +298,10 @@ describe('MemoryRepository', () => { const result = await repo.startSession({ userId: 'user-456', agentId: 'wren', - workspaceId: 'ws-abc-123', + studioId: 'ws-abc-123', }); + expect(result.studioId).toBe('ws-abc-123'); expect(result.workspaceId).toBe('ws-abc-123'); // Verify insert was called with workspace_id @@ -308,6 +310,32 @@ describe('MemoryRepository', () => { ); }); + it('should prefer studioId over workspaceId when both are provided', async () => { + const mockSessionRow = { + id: 'session-studio-wins', + user_id: 'user-456', + agent_id: 'wren', + workspace_id: 'studio-abc', + started_at: '2026-02-10T00:00:00Z', + ended_at: null, + summary: null, + metadata: {}, + }; + + mockSupabase._setReturnData(mockSessionRow); + + await repo.startSession({ + userId: 'user-456', + agentId: 'wren', + studioId: 'studio-abc', + workspaceId: 'workspace-legacy', + }); + + expect(mockSupabase._queryBuilder.insert).toHaveBeenCalledWith( + expect.objectContaining({ workspace_id: 'studio-abc' }), + ); + }); + it('should not include workspace_id in insert when workspaceId is omitted', async () => { const mockSessionRow = { id: 'session-no-ws', @@ -463,6 +491,14 @@ describe('MemoryRepository', () => { expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('workspace_id', 'ws-filter'); }); + it('should filter by studioId when provided', async () => { + mockSupabase._setArrayData([]); + + await repo.listSessions('user-456', { studioId: 'studio-filter' }); + + expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('workspace_id', 'studio-filter'); + }); + it('should not filter by workspace when workspaceId is omitted', async () => { mockSupabase._setArrayData([]); @@ -475,7 +511,7 @@ describe('MemoryRepository', () => { }); describe('rowToSession mapping', () => { - it('should map workspace_id to workspaceId', async () => { + it('should map workspace_id to both studioId and workspaceId', async () => { const mockSessionRow = { id: 'session-map', user_id: 'user-456', @@ -490,6 +526,7 @@ describe('MemoryRepository', () => { mockSupabase._setReturnData(mockSessionRow); const result = await repo.getSession('session-map'); + expect(result!.studioId).toBe('ws-mapped'); expect(result!.workspaceId).toBe('ws-mapped'); }); @@ -508,6 +545,7 @@ describe('MemoryRepository', () => { mockSupabase._setReturnData(mockSessionRow); const result = await repo.getSession('session-null-ws'); + expect(result!.studioId).toBeUndefined(); expect(result!.workspaceId).toBeUndefined(); }); }); diff --git a/packages/api/src/data/repositories/memory-repository.ts b/packages/api/src/data/repositories/memory-repository.ts index 8855eb43..a5441956 100644 --- a/packages/api/src/data/repositories/memory-repository.ts +++ b/packages/api/src/data/repositories/memory-repository.ts @@ -196,8 +196,9 @@ export class MemoryRepository { agent_id: input.agentId, metadata: input.metadata || {}, }; - if (input.workspaceId !== undefined) { - insertData.workspace_id = input.workspaceId; + const scopedStudioId = input.studioId ?? input.workspaceId; + if (scopedStudioId !== undefined) { + insertData.workspace_id = scopedStudioId; } const { data, error } = await this.supabase @@ -306,12 +307,12 @@ export class MemoryRepository { /** * Get active session for a user (most recent without ended_at). * - * workspaceId behavior: - * - undefined: don't filter by workspace (backward compat — finds any active session) - * - null: match sessions with no workspace - * - string: match that specific workspace + * studioId/workspaceId behavior: + * - undefined: don't filter by studio/workspace (backward compat — finds any active session) + * - null: match sessions with no studio/workspace + * - string: match that specific studio/workspace */ - async getActiveSession(userId: string, agentId?: string, workspaceId?: string | null): Promise { + async getActiveSession(userId: string, agentId?: string, studioId?: string | null): Promise { let query = this.supabase .from('sessions') .select('*') @@ -324,11 +325,11 @@ export class MemoryRepository { query = query.eq('agent_id', agentId); } - if (workspaceId !== undefined) { - if (workspaceId === null) { + if (studioId !== undefined) { + if (studioId === null) { query = query.is('workspace_id', null); } else { - query = query.eq('workspace_id', workspaceId); + query = query.eq('workspace_id', studioId); } } @@ -374,7 +375,7 @@ export class MemoryRepository { */ async listSessions( userId: string, - options: { limit?: number; offset?: number; agentId?: string; workspaceId?: string } = {} + options: { limit?: number; offset?: number; agentId?: string; studioId?: string; workspaceId?: string } = {} ): Promise { let query = this.supabase .from('sessions') @@ -386,8 +387,9 @@ export class MemoryRepository { query = query.eq('agent_id', options.agentId); } - if (options.workspaceId) { - query = query.eq('workspace_id', options.workspaceId); + const scopedStudioId = options.studioId ?? options.workspaceId; + if (scopedStudioId) { + query = query.eq('workspace_id', scopedStudioId); } const limit = options.limit || 20; @@ -691,11 +693,13 @@ export class MemoryRepository { } private rowToSession(row: SessionRow): Session { + const studioId = row.workspace_id || undefined; return { id: row.id, userId: row.user_id, agentId: row.agent_id || undefined, - workspaceId: row.workspace_id || undefined, + studioId, + workspaceId: studioId, currentPhase: row.current_phase || undefined, startedAt: new Date(row.started_at), endedAt: row.ended_at ? new Date(row.ended_at) : undefined, diff --git a/packages/api/src/data/repositories/workspaces.repository.ts b/packages/api/src/data/repositories/workspaces.repository.ts index 50dbe4ee..b8869df2 100644 --- a/packages/api/src/data/repositories/workspaces.repository.ts +++ b/packages/api/src/data/repositories/workspaces.repository.ts @@ -197,6 +197,27 @@ export class WorkspacesRepository { return (data || []).map((row) => this.mapRow(row as Record)); } + /** + * List workspaces by IDs for a specific user. + */ + async listByIds(userId: string, ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + const { data, error } = await this.client + .from('workspaces') + .select('*') + .eq('user_id', userId) + .in('id', ids); + + if (error) { + throw new Error(`Failed to list workspaces by ids: ${error.message}`); + } + + return (data || []).map((row) => this.mapRow(row as Record)); + } + /** * List active workspaces for a user (status in 'active' or 'idle') */ diff --git a/packages/api/src/mcp/tools/index.ts b/packages/api/src/mcp/tools/index.ts index 568e9b1d..6f92671e 100644 --- a/packages/api/src/mcp/tools/index.ts +++ b/packages/api/src/mcp/tools/index.ts @@ -878,7 +878,8 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId metadata: z.record(z.unknown()).optional().describe('Additional metadata'), expiresAt: z.string().datetime().optional().describe('Optional expiration date (ISO 8601)'), agentId: z.string().optional().describe('Which AI being created this memory (e.g., "wren", "benson"). Null = shared memory.'), - workspaceId: z.string().uuid().optional().describe('Workspace ID — helps auto-attach the correct session in parallel worktree scenarios. Stored in metadata.'), + studioId: z.string().uuid().optional().describe('Studio ID — helps auto-attach the correct session in parallel worktree scenarios. Stored in metadata.'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), }, }, async (args) => { @@ -991,15 +992,17 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId { description: `Start a new AI session. Sessions track work done across a conversation and can be logged to. -If workspaceId is provided, the session is scoped to that workspace — allowing multiple active sessions per agent (one per workspace). Read workspaceId from .pcp/identity.json if available. +If studioId is provided, the session is scoped to that studio — allowing multiple active sessions per agent (one per studio). Read studioId from .pcp/identity.json if available. +workspaceId is accepted as a deprecated alias. -If an active session already exists for this agent+workspace, it is returned instead of creating a new one. +If an active session already exists for this agent+studio, it is returned instead of creating a new one. User can be identified by ONE of: userId, email, phone, or platform + platformId`, inputSchema: { ...userIdentifierFields, agentId: z.string().optional().describe('Agent identifier (e.g., "claude-code", "telegram-myra")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID to scope this session to. Allows multiple active sessions per agent (one per workspace). Read from .pcp/identity.json.'), + studioId: z.string().uuid().optional().describe('Studio ID to scope this session to. Allows multiple active sessions per agent (one per studio). Read from .pcp/identity.json.'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), metadata: z.record(z.unknown()).optional().describe('Session metadata'), }, }, @@ -1027,7 +1030,8 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId ...userIdentifierFields, sessionId: z.string().uuid().optional().describe('Session ID (uses active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), content: z.string().describe('Log entry content'), salience: z.enum(['low', 'medium', 'high', 'critical']).optional().describe('Importance (default: medium)'), }, @@ -1051,14 +1055,16 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId { description: `End a session with an optional summary. The summary is automatically saved as a high-salience memory. -Session resolution: sessionId (explicit) > agentId+workspaceId (scoped) > most recent active (fallback). +Session resolution: sessionId (explicit) > agentId+studioId (scoped) > most recent active (fallback). +workspaceId is accepted as a deprecated alias. User can be identified by ONE of: userId, email, phone, or platform + platformId`, inputSchema: { ...userIdentifierFields, sessionId: z.string().uuid().optional().describe('Session ID (uses active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), summary: z.string().optional().describe('End-of-session summary (saved as memory)'), }, }, @@ -1086,7 +1092,8 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId ...userIdentifierFields, sessionId: z.string().uuid().optional().describe('Session ID (returns active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), includeLogs: z.boolean().optional().describe('Include session logs (default: false)'), }, }, @@ -1113,7 +1120,8 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId inputSchema: { ...userIdentifierFields, agentId: z.string().optional().describe('Filter by agent'), - workspaceId: z.string().uuid().optional().describe('Filter by workspace'), + studioId: z.string().uuid().optional().describe('Filter by studio'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), limit: z.number().min(1).max(100).optional().describe('Max results (default: 20)'), }, }, @@ -1136,8 +1144,9 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId { description: `Update your session state — work phase, status, backend session ID, context. This is the primary tool for managing session state. -Session resolution: sessionId (explicit) > workspaceId (scoped lookup) > most recent active session. -For parallel worktrees, pass workspaceId to target the correct session. +Session resolution: sessionId (explicit) > studioId (scoped lookup) > most recent active session. +For parallel worktrees, pass studioId to target the correct session. +workspaceId is accepted as a deprecated alias. Phase: Communicates real-time work status to other agents. - Active work phases (no auto-memory): investigating, implementing, reviewing @@ -1150,7 +1159,8 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId inputSchema: { ...userIdentifierFields, sessionId: z.string().uuid().optional().describe('Session ID (uses active session if not provided). Most reliable for targeting a specific session.'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId is not provided. Useful for parallel worktree scenarios.'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId is not provided. Useful for parallel worktree scenarios.'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), phase: z.string().optional().describe('Work phase (e.g., "implementing", "blocked:awaiting-input", "waiting:build")'), note: z.string().optional().describe('Context for the phase transition (included in auto-created memory for blocked/waiting)'), agentId: z.string().optional().describe('Agent identity for memory attribution'), @@ -1314,7 +1324,8 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId ...userIdentifierFields, sessionId: z.string().uuid().optional().describe('Session ID to compact (uses active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), minSalience: z.enum(['low', 'medium', 'high', 'critical']).optional() .describe('Minimum salience to include (default: medium)'), preserveLogs: z.boolean().optional().describe('Keep original logs visible after compaction (default: false). Note: Logs are always soft-deleted for audit trail.'), diff --git a/packages/api/src/mcp/tools/memory-handlers.test.ts b/packages/api/src/mcp/tools/memory-handlers.test.ts index ed01b0b6..d4c23240 100644 --- a/packages/api/src/mcp/tools/memory-handlers.test.ts +++ b/packages/api/src/mcp/tools/memory-handlers.test.ts @@ -92,20 +92,20 @@ function createMockDataComposer() { // ===================================================== describe('startSessionSchema', () => { - it('should accept workspaceId as optional UUID', () => { + it('should accept studioId as optional UUID', () => { const result = startSessionSchema.safeParse({ email: 'test@test.com', agentId: 'wren', - workspaceId: '550e8400-e29b-41d4-a716-446655440000', + studioId: '550e8400-e29b-41d4-a716-446655440000', }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(result.data.studioId).toBe('550e8400-e29b-41d4-a716-446655440000'); } }); - it('should accept request without workspaceId (backward compat)', () => { + it('should accept request without studioId', () => { const result = startSessionSchema.safeParse({ email: 'test@test.com', agentId: 'wren', @@ -113,15 +113,25 @@ describe('startSessionSchema', () => { expect(result.success).toBe(true); if (result.success) { - expect(result.data.workspaceId).toBeUndefined(); + expect(result.data.studioId).toBeUndefined(); } }); - it('should reject non-UUID workspaceId', () => { + it('should accept deprecated workspaceId alias', () => { + const result = startSessionSchema.safeParse({ + email: 'test@test.com', + agentId: 'wren', + workspaceId: '550e8400-e29b-41d4-a716-446655440000', + }); + + expect(result.success).toBe(true); + }); + + it('should reject non-UUID studioId', () => { const result = startSessionSchema.safeParse({ email: 'test@test.com', agentId: 'wren', - workspaceId: 'not-a-uuid', + studioId: 'not-a-uuid', }); expect(result.success).toBe(false); @@ -130,7 +140,7 @@ describe('startSessionSchema', () => { it('should still require user identification', () => { const result = startSessionSchema.safeParse({ agentId: 'wren', - workspaceId: '550e8400-e29b-41d4-a716-446655440000', + studioId: '550e8400-e29b-41d4-a716-446655440000', }); // The base schema allows resolution by userId, email, phone, or platform+platformId @@ -140,19 +150,19 @@ describe('startSessionSchema', () => { }); describe('listSessionsSchema', () => { - it('should accept workspaceId as optional UUID', () => { + it('should accept studioId as optional UUID', () => { const result = listSessionsSchema.safeParse({ email: 'test@test.com', - workspaceId: '550e8400-e29b-41d4-a716-446655440000', + studioId: '550e8400-e29b-41d4-a716-446655440000', }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(result.data.studioId).toBe('550e8400-e29b-41d4-a716-446655440000'); } }); - it('should accept request without workspaceId', () => { + it('should accept request without studioId', () => { const result = listSessionsSchema.safeParse({ email: 'test@test.com', agentId: 'wren', @@ -160,30 +170,39 @@ describe('listSessionsSchema', () => { expect(result.success).toBe(true); if (result.success) { - expect(result.data.workspaceId).toBeUndefined(); + expect(result.data.studioId).toBeUndefined(); } }); - it('should accept both agentId and workspaceId together', () => { + it('should accept both agentId and studioId together', () => { const result = listSessionsSchema.safeParse({ email: 'test@test.com', agentId: 'wren', - workspaceId: '550e8400-e29b-41d4-a716-446655440000', + studioId: '550e8400-e29b-41d4-a716-446655440000', limit: 10, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.agentId).toBe('wren'); - expect(result.data.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(result.data.studioId).toBe('550e8400-e29b-41d4-a716-446655440000'); expect(result.data.limit).toBe(10); } }); - it('should reject non-UUID workspaceId', () => { + it('should accept deprecated workspaceId alias', () => { const result = listSessionsSchema.safeParse({ email: 'test@test.com', - workspaceId: 'invalid', + workspaceId: '550e8400-e29b-41d4-a716-446655440000', + }); + + expect(result.success).toBe(true); + }); + + it('should reject non-UUID studioId', () => { + const result = listSessionsSchema.safeParse({ + email: 'test@test.com', + studioId: 'invalid', }); expect(result.success).toBe(false); @@ -241,7 +260,7 @@ describe('updateSessionPhaseSchema', () => { context: 'Working on session phase tests', workingDir: '/Users/test/project', agentId: 'wren', - workspaceId: '550e8400-e29b-41d4-a716-446655440099', + studioId: '550e8400-e29b-41d4-a716-446655440099', }); expect(result.success).toBe(true); @@ -318,6 +337,7 @@ describe('handleUpdateSessionPhase', () => { id: 'session-123', email: 'test@test.com', agentId: 'wren', + studioId: undefined, workspaceId: undefined, currentPhase: undefined, startedAt: new Date('2026-02-10T10:00:00Z'), @@ -390,26 +410,26 @@ describe('handleUpdateSessionPhase', () => { expect(mockDataComposer.repositories.memory.getActiveSession).toHaveBeenCalledWith('user-123', 'wren', undefined); }); - it('should resolve session by workspaceId when sessionId not provided', async () => { + it('should resolve session by studioId when sessionId not provided', async () => { mockDataComposer.repositories.memory.getActiveSession.mockResolvedValue(mockSession); mockDataComposer.repositories.memory.updateSession.mockResolvedValue(mockUpdatedSession); - const workspaceId = '550e8400-e29b-41d4-a716-446655440099'; + const studioId = '550e8400-e29b-41d4-a716-446655440099'; await handleUpdateSessionPhase( - { email: 'test@test.com', phase: 'implementing', agentId: 'wren', workspaceId }, + { email: 'test@test.com', phase: 'implementing', agentId: 'wren', studioId }, mockDataComposer as never ); - expect(mockDataComposer.repositories.memory.getActiveSession).toHaveBeenCalledWith('user-123', 'wren', workspaceId); + expect(mockDataComposer.repositories.memory.getActiveSession).toHaveBeenCalledWith('user-123', 'wren', studioId); }); - it('should prefer sessionId over workspaceId for resolution', async () => { + it('should prefer sessionId over studioId for resolution', async () => { mockDataComposer.repositories.memory.updateSession.mockResolvedValue(mockUpdatedSession); const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - const workspaceId = '550e8400-e29b-41d4-a716-446655440099'; + const studioId = '550e8400-e29b-41d4-a716-446655440099'; await handleUpdateSessionPhase( - { email: 'test@test.com', phase: 'reviewing', sessionId, workspaceId }, + { email: 'test@test.com', phase: 'reviewing', sessionId, studioId }, mockDataComposer as never ); @@ -420,6 +440,19 @@ describe('handleUpdateSessionPhase', () => { expect.objectContaining({ currentPhase: 'reviewing' }) ); }); + + it('should support deprecated workspaceId alias for session resolution', async () => { + mockDataComposer.repositories.memory.getActiveSession.mockResolvedValue(mockSession); + mockDataComposer.repositories.memory.updateSession.mockResolvedValue(mockUpdatedSession); + + const workspaceId = '550e8400-e29b-41d4-a716-446655440099'; + await handleUpdateSessionPhase( + { email: 'test@test.com', phase: 'implementing', agentId: 'wren', workspaceId }, + mockDataComposer as never + ); + + expect(mockDataComposer.repositories.memory.getActiveSession).toHaveBeenCalledWith('user-123', 'wren', workspaceId); + }); }); // --------------------------------------------------- @@ -938,6 +971,7 @@ describe('handleUpdateSessionPhase', () => { it('should include session info in response', async () => { const sessionWithWorkspace = { ...mockSession, + studioId: 'workspace-abc', workspaceId: 'workspace-abc', currentPhase: 'implementing', }; @@ -952,6 +986,7 @@ describe('handleUpdateSessionPhase', () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.session.id).toBe('session-123'); expect(parsed.session.agentId).toBe('wren'); + expect(parsed.session.studioId).toBe('workspace-abc'); expect(parsed.session.workspaceId).toBe('workspace-abc'); expect(parsed.session.currentPhase).toBe('implementing'); }); diff --git a/packages/api/src/mcp/tools/memory-handlers.ts b/packages/api/src/mcp/tools/memory-handlers.ts index 57d16680..389a7209 100644 --- a/packages/api/src/mcp/tools/memory-handlers.ts +++ b/packages/api/src/mcp/tools/memory-handlers.ts @@ -28,6 +28,10 @@ async function safeReadFile(filePath: string): Promise { const memorySourceSchema = z.enum(['conversation', 'observation', 'user_stated', 'inferred', 'session']); const salienceSchema = z.enum(['low', 'medium', 'high', 'critical']); +function resolveStudioId(params: { studioId?: string; workspaceId?: string }): string | undefined { + return params.studioId ?? params.workspaceId; +} + // ===================================================== // MEMORY TOOLS // ===================================================== @@ -40,7 +44,8 @@ export const rememberSchema = userIdentifierBaseSchema.extend({ metadata: z.record(z.unknown()).optional().describe('Additional metadata'), expiresAt: z.string().datetime().optional().describe('Optional expiration date (ISO 8601)'), agentId: z.string().optional().describe('Which AI being created this memory (e.g., "wren", "benson"). Null = shared memory.'), - workspaceId: z.string().uuid().optional().describe('Workspace ID — used to auto-attach the correct session in parallel worktree scenarios. Stored in metadata, not as a first-class field.'), + studioId: z.string().uuid().optional().describe('Studio ID — preferred session scope for parallel worktree scenarios. Stored in metadata, not as a first-class field.'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), }); export const recallSchema = userIdentifierBaseSchema.extend({ @@ -71,14 +76,16 @@ export const updateMemorySchema = userIdentifierBaseSchema.extend({ export const startSessionSchema = userIdentifierBaseSchema.extend({ agentId: z.string().optional().describe('Identifier for the agent (e.g., "claude-code", "telegram-myra")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID to scope this session to. Allows multiple active sessions per agent (one per workspace).'), + studioId: z.string().uuid().optional().describe('Studio ID to scope this session to. Allows multiple active sessions per agent (one per studio).'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), metadata: z.record(z.unknown()).optional().describe('Additional session metadata'), }); export const logSessionSchema = userIdentifierBaseSchema.extend({ sessionId: z.string().uuid().optional().describe('Session ID (uses active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), content: z.string().describe('Log entry content'), salience: salienceSchema.optional().describe('Importance level (default: medium)'), }); @@ -86,20 +93,23 @@ export const logSessionSchema = userIdentifierBaseSchema.extend({ export const endSessionSchema = userIdentifierBaseSchema.extend({ sessionId: z.string().uuid().optional().describe('Session ID (uses active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), summary: z.string().optional().describe('End-of-session summary'), }); export const getSessionSchema = userIdentifierBaseSchema.extend({ sessionId: z.string().uuid().optional().describe('Session ID (returns active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), includeLogs: z.boolean().optional().describe('Include session logs (default: false)'), }); export const listSessionsSchema = userIdentifierBaseSchema.extend({ agentId: z.string().optional().describe('Filter by agent'), - workspaceId: z.string().uuid().optional().describe('Filter by workspace'), + studioId: z.string().uuid().optional().describe('Filter by studio'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), limit: z.number().min(1).max(100).optional().describe('Max results (default: 20)'), }); @@ -109,7 +119,8 @@ export const listSessionsSchema = userIdentifierBaseSchema.extend({ export const updateSessionPhaseSchema = userIdentifierBaseSchema.extend({ sessionId: z.string().uuid().optional().describe('Session ID (uses active session if not provided). Most reliable way to target a specific session.'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution. When sessionId is not provided, finds the active session in this workspace. Useful for parallel worktree scenarios.'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution. When sessionId is not provided, finds the active session in this studio. Useful for parallel worktree scenarios.'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), phase: z.string().optional().describe('Work phase. Core phases: investigating, implementing, reviewing, paused, complete. Use blocked: or waiting: for transitions that auto-create memories.'), note: z.string().optional().describe('Optional note explaining the phase (e.g., what you\'re blocked on). Included in auto-created memory for blocked/waiting phases.'), agentId: z.string().optional().describe('Agent identity for memory attribution'), @@ -156,7 +167,8 @@ export const bootstrapSchema = userIdentifierBaseSchema.extend({ export const compactSessionSchema = userIdentifierBaseSchema.extend({ sessionId: z.string().uuid().optional().describe('Session ID to compact (uses active session if not provided)'), agentId: z.string().optional().describe('Agent identifier for session resolution (e.g., "wren", "benson")'), - workspaceId: z.string().uuid().optional().describe('Workspace ID for session resolution when sessionId not provided'), + studioId: z.string().uuid().optional().describe('Studio ID for session resolution when sessionId not provided'), + workspaceId: z.string().uuid().optional().describe('[Deprecated] Workspace ID alias for studioId.'), groupByTopics: z.boolean().optional().describe('Group logs by inferred topics (default: true)'), minSalience: z.enum(['low', 'medium', 'high', 'critical']).optional() .describe('Minimum salience to include in compaction (default: medium)'), @@ -170,6 +182,7 @@ export const compactSessionSchema = userIdentifierBaseSchema.extend({ export async function handleRemember(args: unknown, dataComposer: DataComposer) { const params = rememberSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); // If there's an active session, attach its ID to the memory metadata for traceability. // Never require a session — memories are too important to lose. @@ -178,7 +191,7 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) const activeSession = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, + studioId, ); sessionId = activeSession?.id; } catch { @@ -188,7 +201,7 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) const metadata = { ...params.metadata, ...(sessionId ? { sessionId } : {}), - ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + ...(studioId ? { studioId, workspaceId: studioId } : {}), }; const memory = await dataComposer.repositories.memory.remember({ @@ -361,12 +374,13 @@ export async function handleUpdateMemory(args: unknown, dataComposer: DataCompos export async function handleStartSession(args: unknown, dataComposer: DataComposer) { const params = startSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); - // Check if there's already an active session for this agent (scoped by workspace if provided) + // Check if there's already an active session for this agent (scoped by studio if provided) const existingSession = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, + studioId, ); if (existingSession) { @@ -382,6 +396,7 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos session: { id: existingSession.id, agentId: existingSession.agentId, + studioId: existingSession.studioId, workspaceId: existingSession.workspaceId, startedAt: existingSession.startedAt.toISOString(), isExisting: true, @@ -398,11 +413,17 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos const session = await dataComposer.repositories.memory.startSession({ userId: user.id, agentId: params.agentId, + studioId, workspaceId: params.workspaceId, metadata: params.metadata, }); - logger.info(`Session started for user ${user.id}`, { sessionId: session.id, agentId: session.agentId, workspaceId: session.workspaceId }); + logger.info(`Session started for user ${user.id}`, { + sessionId: session.id, + agentId: session.agentId, + studioId: session.studioId, + workspaceId: session.workspaceId, + }); return { content: [ @@ -416,6 +437,7 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos session: { id: session.id, agentId: session.agentId, + studioId: session.studioId, workspaceId: session.workspaceId, startedAt: session.startedAt.toISOString(), }, @@ -431,14 +453,15 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos export async function handleLogSession(args: unknown, dataComposer: DataComposer) { const params = logSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); - // Get session ID (use provided or find active, scoped by agent+workspace) + // Get session ID (use provided or find active, scoped by agent+studio) let sessionId = params.sessionId; if (!sessionId) { const activeSession = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, + studioId, ); if (!activeSession) { return { @@ -493,14 +516,15 @@ export async function handleLogSession(args: unknown, dataComposer: DataComposer export async function handleEndSession(args: unknown, dataComposer: DataComposer) { const params = endSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); - // Get session ID (use provided or find active, scoped by agent+workspace) + // Get session ID (use provided or find active, scoped by agent+studio) let sessionId = params.sessionId; if (!sessionId) { const activeSession = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, + studioId, ); if (!activeSession) { return { @@ -554,6 +578,7 @@ export async function handleEndSession(args: unknown, dataComposer: DataComposer session: { id: session.id, agentId: session.agentId, + studioId: session.studioId, workspaceId: session.workspaceId, currentPhase: session.currentPhase || null, startedAt: session.startedAt.toISOString(), @@ -572,6 +597,7 @@ export async function handleEndSession(args: unknown, dataComposer: DataComposer export async function handleGetSession(args: unknown, dataComposer: DataComposer) { const params = getSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); let session; if (params.sessionId) { @@ -580,7 +606,7 @@ export async function handleGetSession(args: unknown, dataComposer: DataComposer session = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, + studioId, ); } @@ -615,6 +641,7 @@ export async function handleGetSession(args: unknown, dataComposer: DataComposer session: { id: session.id, agentId: session.agentId, + studioId: session.studioId, workspaceId: session.workspaceId, currentPhase: session.currentPhase || null, startedAt: session.startedAt.toISOString(), @@ -640,13 +667,26 @@ export async function handleGetSession(args: unknown, dataComposer: DataComposer export async function handleListSessions(args: unknown, dataComposer: DataComposer) { const params = listSessionsSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); const sessions = await dataComposer.repositories.memory.listSessions(user.id, { agentId: params.agentId, + studioId, workspaceId: params.workspaceId, limit: params.limit, }); + const studioIds = Array.from( + new Set( + sessions + .map((s) => s.studioId) + .filter((id): id is string => !!id) + ) + ); + + const workspaces = await dataComposer.repositories.workspaces.listByIds(user.id, studioIds); + const workspaceById = new Map(workspaces.map((w) => [w.id, w])); + return { content: [ { @@ -659,7 +699,20 @@ export async function handleListSessions(args: unknown, dataComposer: DataCompos sessions: sessions.map((s) => ({ id: s.id, agentId: s.agentId, + studioId: s.studioId, workspaceId: s.workspaceId, + studio: s.studioId + ? (() => { + const workspace = workspaceById.get(s.studioId); + if (!workspace) return null; + return { + id: workspace.id, + worktreePath: workspace.worktreePath, + worktreeFolder: path.basename(workspace.worktreePath), + branch: workspace.branch, + }; + })() + : null, currentPhase: s.currentPhase || null, startedAt: s.startedAt.toISOString(), endedAt: s.endedAt?.toISOString(), @@ -689,6 +742,7 @@ function isSignificantPhaseTransition(phase: string): boolean { export async function handleUpdateSessionPhase(args: unknown, dataComposer: DataComposer) { const params = updateSessionPhaseSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); // Require at least one field to update if (!params.phase && !params.backendSessionId && !params.status && !params.context && !params.workingDir) { @@ -706,13 +760,13 @@ export async function handleUpdateSessionPhase(args: unknown, dataComposer: Data }; } - // Resolve session: sessionId > workspaceId-scoped lookup > most recent active + // Resolve session: sessionId > studioId-scoped lookup > most recent active let sessionId = params.sessionId; if (!sessionId) { const session = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, // undefined = no workspace filter (backward compat) + studioId, // undefined = no studio/workspace filter (backward compat) ); if (!session) { return { @@ -783,6 +837,7 @@ export async function handleUpdateSessionPhase(args: unknown, dataComposer: Data session: { id: updated.id, agentId: updated.agentId, + studioId: updated.studioId, workspaceId: updated.workspaceId, currentPhase: updated.currentPhase || null, }, @@ -1231,17 +1286,19 @@ export async function handleBootstrap(args: unknown, dataComposer: DataComposer) ? { id: activeSessions[0].id, agentId: activeSessions[0].agentId, + studioId: activeSessions[0].studioId || null, workspaceId: activeSessions[0].workspaceId || null, currentPhase: activeSessions[0].currentPhase || null, startedAt: activeSessions[0].startedAt.toISOString(), } : null, - // All active sessions — use workspaceId to pick the right one - // Match against .pcp/identity.json workspaceId in your local environment + // All active sessions — use studioId to pick the right one + // Match against .pcp/identity.json studioId/workspaceId in your local environment activeSessions: activeSessions.map((s) => ({ id: s.id, agentId: s.agentId, + studioId: s.studioId || null, workspaceId: s.workspaceId || null, currentPhase: s.currentPhase || null, startedAt: s.startedAt.toISOString(), @@ -1316,6 +1373,7 @@ export async function handleBootstrap(args: unknown, dataComposer: DataComposer) export async function handleCompactSession(args: unknown, dataComposer: DataComposer) { const params = compactSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + const studioId = resolveStudioId(params); const minSalience = params.minSalience || 'medium'; const preserveLogs = params.preserveLogs ?? false; @@ -1330,7 +1388,7 @@ export async function handleCompactSession(args: unknown, dataComposer: DataComp session = await dataComposer.repositories.memory.getActiveSession( user.id, params.agentId, - params.workspaceId, + studioId, ); sessionId = session?.id; } diff --git a/packages/api/src/mcp/tools/workspace-handlers.ts b/packages/api/src/mcp/tools/workspace-handlers.ts index 1f66ce95..78a3b978 100644 --- a/packages/api/src/mcp/tools/workspace-handlers.ts +++ b/packages/api/src/mcp/tools/workspace-handlers.ts @@ -220,8 +220,10 @@ export async function handleCreateWorkspace(args: unknown, dataComposer: DataCom message: `Workspace created at ${worktreePath}`, workspace: { id: workspace.id, + studioId: workspace.id, agentId: workspace.agentId, branch: workspace.branch, + worktreeFolder: path.basename(workspace.worktreePath), worktreePath: workspace.worktreePath, repoRoot: workspace.repoRoot, baseBranch: workspace.baseBranch, @@ -263,8 +265,11 @@ export async function handleListWorkspaces(args: unknown, dataComposer: DataComp count: workspaces.length, workspaces: workspaces.map((w) => ({ id: w.id, + studioId: w.id, agentId: w.agentId, branch: w.branch, + worktreePath: w.worktreePath, + worktreeFolder: path.basename(w.worktreePath), path: w.worktreePath, purpose: w.purpose, status: w.status, @@ -300,8 +305,10 @@ export async function handleGetWorkspace(args: unknown, dataComposer: DataCompos return successResponse({ workspace: { id: workspace.id, + studioId: workspace.id, agentId: workspace.agentId, branch: workspace.branch, + worktreeFolder: path.basename(workspace.worktreePath), worktreePath: workspace.worktreePath, repoRoot: workspace.repoRoot, baseBranch: workspace.baseBranch, @@ -354,8 +361,10 @@ export async function handleUpdateWorkspace(args: unknown, dataComposer: DataCom message: 'Workspace updated', workspace: { id: updated.id, + studioId: updated.id, agentId: updated.agentId, branch: updated.branch, + worktreeFolder: path.basename(updated.worktreePath), worktreePath: updated.worktreePath, purpose: updated.purpose, status: updated.status, @@ -475,8 +484,10 @@ export async function handleAdoptWorkspace(args: unknown, dataComposer: DataComp message: `Workspace adopted by ${agentId} and linked to session ${sessionId}`, workspace: { id: updated.id, + studioId: updated.id, agentId: updated.agentId, branch: updated.branch, + worktreeFolder: path.basename(updated.worktreePath), worktreePath: updated.worktreePath, purpose: updated.purpose, status: updated.status, diff --git a/packages/cli/README.md b/packages/cli/README.md index edd72d63..8890258e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -44,7 +44,7 @@ sb -- --some-future-flag echo "explain this" | sb # Subcommands -sb ws create feat-auth # Create workspace +sb studio create feat-auth # Create studio/workspace sb agent status # Check agent status sb session list # List sessions sb --help # Full help @@ -71,24 +71,28 @@ The agent ID is resolved in order: ## Subcommands -### Workspaces (`sb ws`) +### Studios (`sb studio`) -Git worktree management with per-workspace identity. +Git worktree management with per-studio identity. ```bash -sb ws create # Create workspace with git worktree -sb ws list # List all workspaces -sb ws status # Git status across all workspaces -sb ws remove # Remove workspace (keeps branch) -sb ws clean # Remove workspace + delete branch -sb ws path # Print workspace path -eval $(sb ws cd ) # cd to workspace +sb studio create # Create studio with git worktree +sb studio list # List all studios +sb studio status # Git status across all studios +sb studio remove # Remove studio (keeps branch) +sb studio clean # Remove studio + delete branch +sb studio path # Print studio path +eval $(sb studio cd ) # cd to studio ``` +Backwards compatibility aliases still work: +- `sb ws ...` +- `sb workspace ...` + Options for `create`: -- `-i, --identity ` — Agent ID for this workspace (default: wren) +- `-a, --agent ` — Agent ID for this studio (default: wren) - `-p, --purpose ` — Description -- `-b, --branch ` — Custom branch (default: `workspace/`) +- `-b, --branch ` — Custom branch (default: `/workspace/`) ### Agents (`sb agent`) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2e3b1183..35cf0cd1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,7 +12,7 @@ * sb -b codex "fix the bug" Use Codex CLI backend * sb -b gemini "review this" Use Gemini CLI backend * sb --resume Passthrough flags to backend - * sb ws create Create a workspace + * sb studio create Create a studio (worktree) * sb session list List sessions */ diff --git a/packages/cli/src/commands/workspace.ts b/packages/cli/src/commands/workspace.ts index d7a11b3d..8f97a5d0 100644 --- a/packages/cli/src/commands/workspace.ts +++ b/packages/cli/src/commands/workspace.ts @@ -4,14 +4,14 @@ * Manage git worktrees for parallel development with PCP identity. * * Commands: - * ws init [name] Initialize parent directory structure - * ws create Create a new workspace - * ws list List all workspaces - * ws remove Remove a workspace (keeps branch) - * ws clean Remove workspace and delete branch - * ws status Show status of all workspaces - * ws path Output workspace path (for cd) - * ws cd Print cd command (use with: eval $(sb ws cd foo)) + * studio init [name] Initialize parent directory structure + * studio create Create a new workspace/studio + * studio list List all workspaces/studios + * studio remove Remove a workspace/studio (keeps branch) + * studio clean Remove workspace/studio and delete branch + * studio status Show status of all workspaces/studios + * studio path Output workspace/studio path (for cd) + * studio cd Print cd command (use with: eval $(sb studio cd foo)) */ import { Command } from 'commander'; @@ -282,8 +282,8 @@ function planInit(gitRoot: string, parentName: string): InitResult { async function initWorkspace(parentName: string | undefined, options: { dryRun?: boolean }): Promise { if (!parentName) { console.error(chalk.red('Error: Parent directory name is required.')); - console.error(chalk.dim('Usage: sb ws init ')); - console.error(chalk.dim('Example: sb ws init pcp')); + console.error(chalk.dim('Usage: sb studio init ')); + console.error(chalk.dim('Example: sb studio init pcp')); process.exit(1); } @@ -437,7 +437,7 @@ async function createWorkspace( writeFileSync(join(pcpDir, 'identity.json'), JSON.stringify(identity, null, 2)); - spinner.succeed(`Workspace created: ${name}`); + spinner.succeed(`Studio created: ${name}`); console.log(''); console.log(chalk.dim(' Path: ') + wsPath); console.log(chalk.dim(' Branch: ') + branch); @@ -450,7 +450,7 @@ async function createWorkspace( console.log(chalk.dim(` cd ${wsPath} && sb`)); console.log(''); console.log(chalk.cyan('Or use:')); - console.log(chalk.dim(` eval $(sb ws cd ${name})`)); + console.log(chalk.dim(` eval $(sb studio cd ${name})`)); } catch (error) { spinner.fail(`Failed to create workspace: ${error}`); process.exit(1); @@ -463,14 +463,15 @@ function listCommand(): void { if (workspaces.length === 0) { console.log(chalk.yellow('No workspaces found.')); - console.log(chalk.dim('Create one with: sb ws create ')); + console.log(chalk.dim('Create one with: sb studio create ')); return; } - console.log(chalk.bold('\nPCP Workspaces:\n')); + console.log(chalk.bold('\nPCP Studios:\n')); for (const ws of workspaces) { console.log(chalk.cyan(` ${ws.name}`)); + console.log(chalk.dim(` Folder: ${basename(ws.path)}`)); console.log(chalk.dim(` Path: ${ws.path}`)); console.log(chalk.dim(` Branch: ${ws.branch}`)); if (ws.identity) { @@ -484,20 +485,20 @@ function listCommand(): void { } async function removeWorkspace(name: string): Promise { - const spinner = ora(`Removing workspace: ${name}`).start(); + const spinner = ora(`Removing studio: ${name}`).start(); try { const gitRoot = findGitRoot(); const wsPath = getWorkspacePath(gitRoot, name); if (!existsSync(wsPath)) { - spinner.fail(`Workspace not found: ${name}`); + spinner.fail(`Studio not found: ${name}`); process.exit(1); } git(`worktree remove "${wsPath}"`, gitRoot); - spinner.succeed(`Workspace removed: ${name}`); - console.log(chalk.dim(' Branch kept for PR. Use "sb ws clean" to also delete branch.')); + spinner.succeed(`Studio removed: ${name}`); + console.log(chalk.dim(' Branch kept for PR. Use "sb studio clean" to also delete branch.')); } catch (error) { spinner.fail(`Failed to remove workspace: ${error}`); process.exit(1); @@ -505,7 +506,7 @@ async function removeWorkspace(name: string): Promise { } async function cleanWorkspace(name: string): Promise { - const spinner = ora(`Cleaning workspace: ${name}`).start(); + const spinner = ora(`Cleaning studio: ${name}`).start(); try { const gitRoot = findGitRoot(); @@ -546,7 +547,7 @@ async function cleanWorkspace(name: string): Promise { git(`branch -D "${branch}"`, gitRoot); } - spinner.succeed(`Cleaned workspace: ${name}`); + spinner.succeed(`Cleaned studio: ${name}`); } catch (error) { spinner.fail(`Failed to clean workspace: ${error}`); process.exit(1); @@ -557,7 +558,7 @@ function statusCommand(): void { const gitRoot = findGitRoot(); const workspaces = listWorkspaces(gitRoot); - console.log(chalk.bold('\nWorkspace Status:\n')); + console.log(chalk.bold('\nStudio Status:\n')); console.log(chalk.cyan(` main (${gitRoot})`)); try { @@ -597,7 +598,7 @@ function pathCommand(name: string): void { const wsPath = getWorkspacePath(gitRoot, name); if (!existsSync(wsPath)) { - console.error(`Workspace not found: ${name}`); + console.error(`Studio not found: ${name}`); process.exit(1); } @@ -609,7 +610,7 @@ function cdCommand(name: string): void { const wsPath = getWorkspacePath(gitRoot, name); if (!existsSync(wsPath)) { - console.error(`Workspace not found: ${name}`); + console.error(`Studio not found: ${name}`); process.exit(1); } @@ -627,9 +628,10 @@ export type { InitResult }; export function registerWorkspaceCommands(program: Command): void { const ws = program - .command('ws') + .command('studio') + .alias('ws') .alias('workspace') - .description('Workspace management for parallel development'); + .description('Studio management for parallel development (worktree-backed)'); ws.command('init [parent-name]') .description('Initialize parent directory structure (groups repo + worktrees)') @@ -686,6 +688,6 @@ export function registerWorkspaceCommands(program: Command): void { .action(pathCommand); ws.command('cd ') - .description('Output cd command (use with: eval $(sb ws cd ))') + .description('Output cd command (use with: eval $(sb studio cd ))') .action(cdCommand); } From 4eb0d6427fe75d980a404a850ed2dc28a7544e1d Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 19:14:31 -0800 Subject: [PATCH 02/10] feat: add workspace container model with MCP tools --- packages/api/src/data/composer.ts | 3 + .../workspace-containers.repository.ts | 234 ++++++++++++++++++ packages/api/src/data/supabase/types.ts | 86 +++++++ packages/api/src/mcp/tools/index.ts | 220 +++++++++++++++- .../workspace-container-handlers.test.ts | 119 +++++++++ .../mcp/tools/workspace-container-handlers.ts | 191 ++++++++++++++ .../017_add_workspace_containers.sql | 83 +++++++ 7 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/data/repositories/workspace-containers.repository.ts create mode 100644 packages/api/src/mcp/tools/workspace-container-handlers.test.ts create mode 100644 packages/api/src/mcp/tools/workspace-container-handlers.ts create mode 100644 supabase/migrations/017_add_workspace_containers.sql diff --git a/packages/api/src/data/composer.ts b/packages/api/src/data/composer.ts index 1ba0a943..5dd5e4dd 100644 --- a/packages/api/src/data/composer.ts +++ b/packages/api/src/data/composer.ts @@ -14,6 +14,7 @@ import { ProjectTasksRepository } from './repositories/project-tasks.repository' import { MemoryRepository } from './repositories/memory-repository'; import { ActivityStreamRepository } from './repositories/activity-stream.repository'; import { WorkspacesRepository } from './repositories/workspaces.repository'; +import { WorkspaceContainersRepository } from './repositories/workspace-containers.repository'; import { logger } from '../utils/logger'; export class DataComposer { @@ -33,6 +34,7 @@ export class DataComposer { memory: MemoryRepository; activityStream: ActivityStreamRepository; workspaces: WorkspacesRepository; + workspaceContainers: WorkspaceContainersRepository; }; private constructor(supabaseClient: SupabaseClient) { @@ -53,6 +55,7 @@ export class DataComposer { memory: new MemoryRepository(supabaseClient), activityStream: new ActivityStreamRepository(supabaseClient), workspaces: new WorkspacesRepository(supabaseClient), + workspaceContainers: new WorkspaceContainersRepository(supabaseClient), }; logger.info('Data composer initialized with all repositories'); diff --git a/packages/api/src/data/repositories/workspace-containers.repository.ts b/packages/api/src/data/repositories/workspace-containers.repository.ts new file mode 100644 index 00000000..f2a20d13 --- /dev/null +++ b/packages/api/src/data/repositories/workspace-containers.repository.ts @@ -0,0 +1,234 @@ +/** + * Workspace Containers Repository + * + * Product-level workspace containers (personal/team), distinct from git worktree studios. + */ + +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database, Json } from '../supabase/types'; + +type WorkspaceContainersTable = Database['public']['Tables']['workspace_containers']; +type WorkspaceMembersTable = Database['public']['Tables']['workspace_members']; + +export type WorkspaceContainerType = 'personal' | 'team'; +export type WorkspaceMemberRole = 'owner' | 'admin' | 'member' | 'viewer'; + +export interface WorkspaceContainer { + id: string; + userId: string; + name: string; + slug: string; + type: WorkspaceContainerType; + description: string | null; + metadata: Json; + createdAt: string; + updatedAt: string; + archivedAt: string | null; +} + +export interface WorkspaceMember { + id: string; + workspaceId: string; + userId: string; + role: WorkspaceMemberRole; + createdAt: string; +} + +export interface CreateWorkspaceContainerInput { + userId: string; + name: string; + slug: string; + type?: WorkspaceContainerType; + description?: string; + metadata?: Json; +} + +export interface UpdateWorkspaceContainerInput { + name?: string; + slug?: string; + type?: WorkspaceContainerType; + description?: string | null; + metadata?: Json; + archivedAt?: string | null; +} + +export class WorkspaceContainersRepository { + constructor(private client: SupabaseClient) {} + + private mapContainerRow(row: Record): WorkspaceContainer { + return { + id: row.id as string, + userId: row.user_id as string, + name: row.name as string, + slug: row.slug as string, + type: row.type as WorkspaceContainerType, + description: (row.description as string) || null, + metadata: (row.metadata as Json) || {}, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + archivedAt: (row.archived_at as string) || null, + }; + } + + private mapMemberRow(row: Record): WorkspaceMember { + return { + id: row.id as string, + workspaceId: row.workspace_id as string, + userId: row.user_id as string, + role: row.role as WorkspaceMemberRole, + createdAt: row.created_at as string, + }; + } + + async create(input: CreateWorkspaceContainerInput): Promise { + const insertData: WorkspaceContainersTable['Insert'] = { + user_id: input.userId, + name: input.name, + slug: input.slug, + type: input.type || 'personal', + description: input.description, + metadata: input.metadata || {}, + }; + + const { data, error } = await this.client + .from('workspace_containers') + .insert(insertData) + .select() + .single(); + + if (error) { + throw new Error(`Failed to create workspace container: ${error.message}`); + } + + return this.mapContainerRow(data as Record); + } + + async findById(id: string, userId: string): Promise { + const { data, error } = await this.client + .from('workspace_containers') + .select('*') + .eq('id', id) + .eq('user_id', userId) + .single(); + + if (error && error.code !== 'PGRST116') { + throw new Error(`Failed to find workspace container: ${error.message}`); + } + + return data ? this.mapContainerRow(data as Record) : null; + } + + async listByUser(userId: string, opts?: { + type?: WorkspaceContainerType; + includeArchived?: boolean; + }): Promise { + let query = this.client + .from('workspace_containers') + .select('*') + .eq('user_id', userId) + .order('updated_at', { ascending: false }); + + if (opts?.type) { + query = query.eq('type', opts.type); + } + + if (!opts?.includeArchived) { + query = query.is('archived_at', null); + } + + const { data, error } = await query; + + if (error) { + throw new Error(`Failed to list workspace containers: ${error.message}`); + } + + return (data || []).map((row) => this.mapContainerRow(row as Record)); + } + + async update(id: string, userId: string, input: UpdateWorkspaceContainerInput): Promise { + const updateData: WorkspaceContainersTable['Update'] = {}; + + if (input.name !== undefined) updateData.name = input.name; + if (input.slug !== undefined) updateData.slug = input.slug; + if (input.type !== undefined) updateData.type = input.type; + if (input.description !== undefined) updateData.description = input.description; + if (input.metadata !== undefined) updateData.metadata = input.metadata; + if (input.archivedAt !== undefined) updateData.archived_at = input.archivedAt; + + const { data, error } = await this.client + .from('workspace_containers') + .update(updateData) + .eq('id', id) + .eq('user_id', userId) + .select() + .single(); + + if (error) { + throw new Error(`Failed to update workspace container: ${error.message}`); + } + + return this.mapContainerRow(data as Record); + } + + async ensurePersonalWorkspace(userId: string): Promise { + const { data: existing, error: existingError } = await this.client + .from('workspace_containers') + .select('*') + .eq('user_id', userId) + .eq('slug', 'personal') + .is('archived_at', null) + .maybeSingle(); + + if (existingError) { + throw new Error(`Failed to look up personal workspace: ${existingError.message}`); + } + + if (existing) { + return this.mapContainerRow(existing as Record); + } + + const created = await this.create({ + userId, + name: 'Personal', + slug: 'personal', + type: 'personal', + }); + + await this.addMember(created.id, userId, 'owner'); + return created; + } + + async addMember(workspaceId: string, memberUserId: string, role: WorkspaceMemberRole): Promise { + const insertData: WorkspaceMembersTable['Insert'] = { + workspace_id: workspaceId, + user_id: memberUserId, + role, + }; + + const { data, error } = await this.client + .from('workspace_members') + .insert(insertData) + .select() + .single(); + + if (error) { + throw new Error(`Failed to add workspace member: ${error.message}`); + } + + return this.mapMemberRow(data as Record); + } + + async listMembers(workspaceId: string): Promise { + const { data, error } = await this.client + .from('workspace_members') + .select('*') + .eq('workspace_id', workspaceId) + .order('created_at', { ascending: true }); + + if (error) { + throw new Error(`Failed to list workspace members: ${error.message}`); + } + + return (data || []).map((row) => this.mapMemberRow(row as Record)); + } +} diff --git a/packages/api/src/data/supabase/types.ts b/packages/api/src/data/supabase/types.ts index efc6e004..1219a0cf 100644 --- a/packages/api/src/data/supabase/types.ts +++ b/packages/api/src/data/supabase/types.ts @@ -2485,6 +2485,92 @@ export type Database = { } Relationships: [] } + workspace_containers: { + Row: { + archived_at: string | null + created_at: string | null + description: string | null + id: string + metadata: Json | null + name: string + slug: string + type: string + updated_at: string | null + user_id: string + } + Insert: { + archived_at?: string | null + created_at?: string | null + description?: string | null + id?: string + metadata?: Json | null + name: string + slug: string + type?: string + updated_at?: string | null + user_id: string + } + Update: { + archived_at?: string | null + created_at?: string | null + description?: string | null + id?: string + metadata?: Json | null + name?: string + slug?: string + type?: string + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "workspace_containers_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + workspace_members: { + Row: { + created_at: string | null + id: string + role: string + user_id: string + workspace_id: string + } + Insert: { + created_at?: string | null + id?: string + role?: string + user_id: string + workspace_id: string + } + Update: { + created_at?: string | null + id?: string + role?: string + user_id?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "workspace_members_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "workspace_members_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, + ] + } workspaces: { Row: { agent_id: string | null diff --git a/packages/api/src/mcp/tools/index.ts b/packages/api/src/mcp/tools/index.ts index 6f92671e..7de16f35 100644 --- a/packages/api/src/mcp/tools/index.ts +++ b/packages/api/src/mcp/tools/index.ts @@ -227,6 +227,17 @@ import { workspaceToolDefinitions, } from './workspace-handlers'; +import { + handleCreateWorkspaceContainer, + handleListWorkspaceContainers, + handleGetWorkspaceContainer, + handleUpdateWorkspaceContainer, + createWorkspaceContainerSchema, + listWorkspaceContainersSchema, + getWorkspaceContainerSchema, + updateWorkspaceContainerSchema, +} from './workspace-container-handlers'; + import { handleCreateKindleToken, createKindleTokenSchema, @@ -3025,7 +3036,97 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId ); // ===================================================== - // WORKSPACE TOOLS (git worktree management) + // WORKSPACE CONTAINER TOOLS (personal/team scope) + // ===================================================== + + server.registerTool( + 'create_workspace_container', + { + description: `Create a top-level workspace container (personal/team scope). This is distinct from git worktree studios. + +Use this for Notion/Slack/Linear-style workspace boundaries. + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: createWorkspaceContainerSchema, + }, + async (args: Record) => { + try { + return await handleCreateWorkspaceContainer(args, dataComposer); + } catch (error) { + logger.error('Error in create_workspace_container:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'list_workspace_containers', + { + description: `List top-level workspace containers (personal/team scope). Ensures a default personal workspace exists unless disabled. + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: listWorkspaceContainersSchema, + }, + async (args: Record) => { + try { + return await handleListWorkspaceContainers(args, dataComposer); + } catch (error) { + logger.error('Error in list_workspace_containers:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'get_workspace_container', + { + description: `Get one workspace container by ID. Optionally include member list. + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: getWorkspaceContainerSchema, + }, + async (args: Record) => { + try { + return await handleGetWorkspaceContainer(args, dataComposer); + } catch (error) { + logger.error('Error in get_workspace_container:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'update_workspace_container', + { + description: `Update workspace container metadata (name, slug, type, description, archive state). + +User can be identified by ONE of: userId, email, phone, or platform + platformId`, + inputSchema: updateWorkspaceContainerSchema, + }, + async (args: Record) => { + try { + return await handleUpdateWorkspaceContainer(args, dataComposer); + } catch (error) { + logger.error('Error in update_workspace_container:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + // ===================================================== + // STUDIO TOOLS (legacy `workspace` naming for git worktree management) // ===================================================== server.registerTool( @@ -3157,6 +3258,123 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId } ); + // Studio-first aliases (preferred naming). + // Backward compatibility: legacy workspace tool names remain available above. + + server.registerTool( + 'create_studio', + { + description: `Create a new git worktree studio for isolated parallel work.`, + inputSchema: workspaceToolDefinitions[0].schema, + }, + async (args: Record) => { + try { + return await handleCreateWorkspace(args, dataComposer); + } catch (error) { + logger.error('Error in create_studio:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'list_studios', + { + description: `List git worktree studios (legacy workspace records).`, + inputSchema: workspaceToolDefinitions[1].schema, + }, + async (args: Record) => { + try { + return await handleListWorkspaces(args, dataComposer); + } catch (error) { + logger.error('Error in list_studios:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'get_studio', + { + description: `Get one git worktree studio by ID, branch, or path.`, + inputSchema: workspaceToolDefinitions[2].schema, + }, + async (args: Record) => { + try { + return await handleGetWorkspace(args, dataComposer); + } catch (error) { + logger.error('Error in get_studio:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'update_studio', + { + description: `Update a git worktree studio status, purpose, or session link.`, + inputSchema: workspaceToolDefinitions[3].schema, + }, + async (args: Record) => { + try { + return await handleUpdateWorkspace(args, dataComposer); + } catch (error) { + logger.error('Error in update_studio:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'close_studio', + { + description: `Close a git worktree studio and optionally clean worktree/branch.`, + inputSchema: workspaceToolDefinitions[4].schema, + }, + async (args: Record) => { + try { + return await handleCloseWorkspace(args, dataComposer); + } catch (error) { + logger.error('Error in close_studio:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + + server.registerTool( + 'adopt_studio', + { + description: `Adopt an existing git worktree studio into a new session.`, + inputSchema: workspaceToolDefinitions[5].schema, + }, + async (args: Record) => { + try { + return await handleAdoptWorkspace(args, dataComposer); + } catch (error) { + logger.error('Error in adopt_studio:', error); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }], + isError: true, + }; + } + } + ); + // ===================================================== // Kindle Tools // ===================================================== diff --git a/packages/api/src/mcp/tools/workspace-container-handlers.test.ts b/packages/api/src/mcp/tools/workspace-container-handlers.test.ts new file mode 100644 index 00000000..35019f99 --- /dev/null +++ b/packages/api/src/mcp/tools/workspace-container-handlers.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createWorkspaceContainerSchema, + listWorkspaceContainersSchema, + handleCreateWorkspaceContainer, + handleListWorkspaceContainers, +} from './workspace-container-handlers'; + +vi.mock('../../services/user-resolver', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserOrThrow: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + resolvedBy: 'email', + }), + }; +}); + +function createMockDataComposer() { + return { + repositories: { + workspaceContainers: { + create: vi.fn(), + addMember: vi.fn(), + ensurePersonalWorkspace: vi.fn(), + listByUser: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listMembers: vi.fn(), + }, + }, + }; +} + +describe('workspace-container schemas', () => { + it('accepts create payload and defaults type', () => { + const parsed = createWorkspaceContainerSchema.safeParse({ + email: 'test@test.com', + name: 'PCP Team', + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.type).toBe('personal'); + } + }); + + it('accepts list payload and defaults ensurePersonal', () => { + const parsed = listWorkspaceContainersSchema.safeParse({ + email: 'test@test.com', + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.ensurePersonal).toBe(true); + } + }); +}); + +describe('workspace-container handlers', () => { + let mockDataComposer: ReturnType; + + beforeEach(() => { + mockDataComposer = createMockDataComposer(); + vi.clearAllMocks(); + }); + + it('create handler creates workspace and owner membership', async () => { + mockDataComposer.repositories.workspaceContainers.create.mockResolvedValue({ + id: 'ws-1', + userId: 'user-123', + name: 'PCP Team', + slug: 'pcp-team', + type: 'team', + description: null, + metadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + archivedAt: null, + }); + mockDataComposer.repositories.workspaceContainers.addMember.mockResolvedValue({ + id: 'member-1', + workspaceId: 'ws-1', + userId: 'user-123', + role: 'owner', + createdAt: '2026-01-01T00:00:00.000Z', + }); + + const result = await handleCreateWorkspaceContainer( + { email: 'test@test.com', name: 'PCP Team', type: 'team' }, + mockDataComposer as never, + ); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.workspace.id).toBe('ws-1'); + expect(mockDataComposer.repositories.workspaceContainers.create).toHaveBeenCalled(); + expect(mockDataComposer.repositories.workspaceContainers.addMember).toHaveBeenCalledWith('ws-1', 'user-123', 'owner'); + }); + + it('list handler ensures personal workspace by default', async () => { + mockDataComposer.repositories.workspaceContainers.ensurePersonalWorkspace.mockResolvedValue({ + id: 'personal-1', + }); + mockDataComposer.repositories.workspaceContainers.listByUser.mockResolvedValue([]); + + const result = await handleListWorkspaceContainers( + { email: 'test@test.com' }, + mockDataComposer as never, + ); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(mockDataComposer.repositories.workspaceContainers.ensurePersonalWorkspace).toHaveBeenCalledWith('user-123'); + expect(mockDataComposer.repositories.workspaceContainers.listByUser).toHaveBeenCalled(); + }); +}); + diff --git a/packages/api/src/mcp/tools/workspace-container-handlers.ts b/packages/api/src/mcp/tools/workspace-container-handlers.ts new file mode 100644 index 00000000..c8ad01dc --- /dev/null +++ b/packages/api/src/mcp/tools/workspace-container-handlers.ts @@ -0,0 +1,191 @@ +/** + * Workspace Container Handlers + * + * Product-level workspaces (personal/team), distinct from git worktree studios. + */ + +import { z } from 'zod'; +import type { DataComposer } from '../../data/composer'; +import { resolveUserOrThrow, userIdentifierBaseSchema } from '../../services/user-resolver'; +import type { WorkspaceContainerType } from '../../data/repositories/workspace-containers.repository'; + +const workspaceContainerTypeSchema = z.enum(['personal', 'team']); + +export const createWorkspaceContainerSchema = userIdentifierBaseSchema.extend({ + name: z.string().min(1).describe('Workspace display name (e.g., "Personal", "PCP Team")'), + slug: z.string().min(1).optional().describe('Stable workspace slug (generated from name when omitted)'), + type: workspaceContainerTypeSchema.optional().default('personal') + .describe('Workspace type: personal or team'), + description: z.string().optional().describe('Optional workspace description'), + metadata: z.record(z.unknown()).optional().describe('Optional workspace metadata'), +}); + +export const listWorkspaceContainersSchema = userIdentifierBaseSchema.extend({ + type: workspaceContainerTypeSchema.optional().describe('Optional type filter'), + includeArchived: z.boolean().optional().default(false).describe('Include archived workspaces'), + ensurePersonal: z.boolean().optional().default(true).describe('Ensure a default personal workspace exists'), +}); + +export const getWorkspaceContainerSchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().describe('Workspace container UUID'), + includeMembers: z.boolean().optional().default(false).describe('Include workspace members'), +}); + +export const updateWorkspaceContainerSchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().describe('Workspace container UUID'), + name: z.string().min(1).optional(), + slug: z.string().min(1).optional(), + type: workspaceContainerTypeSchema.optional(), + description: z.string().nullable().optional(), + metadata: z.record(z.unknown()).optional(), + archived: z.boolean().optional().describe('Set true to archive, false to unarchive'), +}); + +function slugify(name: string): string { + const slug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return slug || 'workspace'; +} + +function successResponse(data: Record) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, ...data }) }], + }; +} + +export async function handleCreateWorkspaceContainer(args: unknown, dataComposer: DataComposer) { + const params = createWorkspaceContainerSchema.parse(args); + const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + + const workspace = await dataComposer.repositories.workspaceContainers.create({ + userId: user.id, + name: params.name, + slug: params.slug || slugify(params.name), + type: (params.type || 'personal') as WorkspaceContainerType, + description: params.description, + metadata: params.metadata, + }); + + await dataComposer.repositories.workspaceContainers.addMember(workspace.id, user.id, 'owner'); + + return successResponse({ + user: { id: user.id, resolvedBy }, + workspace: { + id: workspace.id, + userId: workspace.userId, + name: workspace.name, + slug: workspace.slug, + type: workspace.type, + description: workspace.description, + metadata: workspace.metadata, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + archivedAt: workspace.archivedAt, + }, + }); +} + +export async function handleListWorkspaceContainers(args: unknown, dataComposer: DataComposer) { + const params = listWorkspaceContainersSchema.parse(args); + const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + + if (params.ensurePersonal !== false) { + await dataComposer.repositories.workspaceContainers.ensurePersonalWorkspace(user.id); + } + + const workspaces = await dataComposer.repositories.workspaceContainers.listByUser(user.id, { + type: params.type as WorkspaceContainerType | undefined, + includeArchived: params.includeArchived, + }); + + return successResponse({ + user: { id: user.id, resolvedBy }, + count: workspaces.length, + workspaces: workspaces.map((w) => ({ + id: w.id, + userId: w.userId, + name: w.name, + slug: w.slug, + type: w.type, + description: w.description, + metadata: w.metadata, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + archivedAt: w.archivedAt, + })), + }); +} + +export async function handleGetWorkspaceContainer(args: unknown, dataComposer: DataComposer) { + const params = getWorkspaceContainerSchema.parse(args); + const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + + const workspace = await dataComposer.repositories.workspaceContainers.findById(params.workspaceId, user.id); + if (!workspace) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Workspace not found' }) }], + isError: true, + }; + } + + const members = params.includeMembers + ? await dataComposer.repositories.workspaceContainers.listMembers(workspace.id) + : undefined; + + return successResponse({ + user: { id: user.id, resolvedBy }, + workspace: { + id: workspace.id, + userId: workspace.userId, + name: workspace.name, + slug: workspace.slug, + type: workspace.type, + description: workspace.description, + metadata: workspace.metadata, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + archivedAt: workspace.archivedAt, + members: members?.map((m) => ({ + id: m.id, + userId: m.userId, + role: m.role, + createdAt: m.createdAt, + })), + }, + }); +} + +export async function handleUpdateWorkspaceContainer(args: unknown, dataComposer: DataComposer) { + const params = updateWorkspaceContainerSchema.parse(args); + const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + + const updated = await dataComposer.repositories.workspaceContainers.update(params.workspaceId, user.id, { + name: params.name, + slug: params.slug, + type: params.type as WorkspaceContainerType | undefined, + description: params.description, + metadata: params.metadata, + archivedAt: params.archived === undefined ? undefined : (params.archived ? new Date().toISOString() : null), + }); + + return successResponse({ + user: { id: user.id, resolvedBy }, + workspace: { + id: updated.id, + userId: updated.userId, + name: updated.name, + slug: updated.slug, + type: updated.type, + description: updated.description, + metadata: updated.metadata, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + archivedAt: updated.archivedAt, + }, + }); +} + diff --git a/supabase/migrations/017_add_workspace_containers.sql b/supabase/migrations/017_add_workspace_containers.sql new file mode 100644 index 00000000..d1f8f0e2 --- /dev/null +++ b/supabase/migrations/017_add_workspace_containers.sql @@ -0,0 +1,83 @@ +-- Workspace containers (Notion/Slack/Linear style top-level scopes) +-- This is additive and intentionally does NOT replace legacy worktree +-- "workspaces" yet. That migration will follow after all agents are on studio-first flows. + +-- ===================================================== +-- workspace_containers +-- ===================================================== + +CREATE TABLE IF NOT EXISTS workspace_containers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'personal' CHECK (type IN ('personal', 'team')), + description TEXT, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + archived_at TIMESTAMPTZ, + UNIQUE (user_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_workspace_containers_user_id ON workspace_containers(user_id); +CREATE INDEX IF NOT EXISTS idx_workspace_containers_type ON workspace_containers(type); + +DROP TRIGGER IF EXISTS update_workspace_containers_updated_at ON workspace_containers; +CREATE TRIGGER update_workspace_containers_updated_at + BEFORE UPDATE ON workspace_containers + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ===================================================== +-- workspace_members +-- ===================================================== + +CREATE TABLE IF NOT EXISTS workspace_members ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + workspace_id UUID NOT NULL REFERENCES workspace_containers(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (workspace_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_workspace_members_workspace_id ON workspace_members(workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_members_user_id ON workspace_members(user_id); + +-- ===================================================== +-- Security posture +-- ===================================================== + +ALTER TABLE workspace_containers ENABLE ROW LEVEL SECURITY; +ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Service role full access to workspace_containers" ON workspace_containers; +CREATE POLICY "Service role full access to workspace_containers" + ON workspace_containers FOR ALL + USING ((auth.jwt() ->> 'role'::text) = 'service_role'::text); + +DROP POLICY IF EXISTS "Service role full access to workspace_members" ON workspace_members; +CREATE POLICY "Service role full access to workspace_members" + ON workspace_members FOR ALL + USING ((auth.jwt() ->> 'role'::text) = 'service_role'::text); + +-- ===================================================== +-- Backfill default personal workspace for existing users +-- ===================================================== + +INSERT INTO workspace_containers (user_id, name, slug, type) +SELECT u.id, 'Personal', 'personal', 'personal' +FROM users u +WHERE NOT EXISTS ( + SELECT 1 + FROM workspace_containers wc + WHERE wc.user_id = u.id + AND wc.slug = 'personal' +); + +INSERT INTO workspace_members (workspace_id, user_id, role) +SELECT wc.id, wc.user_id, 'owner' +FROM workspace_containers wc +WHERE wc.slug = 'personal' +ON CONFLICT (workspace_id, user_id) DO NOTHING; From a3dd91c86932f8af02d01dc0494d3770b9ae4955 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 22:55:54 -0800 Subject: [PATCH 03/10] feat(api): add studio_id support for session memory flows --- packages/api/src/data/models/memory.ts | 1 + .../repositories/memory-repository.test.ts | 93 +++++++++++++------ .../data/repositories/memory-repository.ts | 18 ++-- .../018_add_studio_id_to_sessions.sql | 57 ++++++++++++ 4 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 supabase/migrations/018_add_studio_id_to_sessions.sql diff --git a/packages/api/src/data/models/memory.ts b/packages/api/src/data/models/memory.ts index 3637ae24..37e0fcd9 100644 --- a/packages/api/src/data/models/memory.ts +++ b/packages/api/src/data/models/memory.ts @@ -137,6 +137,7 @@ export interface SessionRow { id: string; user_id: string; agent_id: string | null; + studio_id: string | null; workspace_id: string | null; current_phase: string | null; started_at: string; diff --git a/packages/api/src/data/repositories/memory-repository.test.ts b/packages/api/src/data/repositories/memory-repository.test.ts index 31f05bc4..5324a734 100644 --- a/packages/api/src/data/repositories/memory-repository.test.ts +++ b/packages/api/src/data/repositories/memory-repository.test.ts @@ -259,6 +259,7 @@ describe('MemoryRepository', () => { id: 'session-123', user_id: 'user-456', agent_id: 'claude-code', + studio_id: null, workspace_id: null, started_at: '2026-01-26T12:00:00Z', ended_at: null, @@ -281,11 +282,12 @@ describe('MemoryRepository', () => { expect(result.endedAt).toBeUndefined(); }); - it('should include workspace_id in insert when studioId is provided', async () => { + it('should include studio_id and workspace_id in insert when studioId is provided', async () => { const mockSessionRow = { id: 'session-ws', user_id: 'user-456', agent_id: 'wren', + studio_id: 'ws-abc-123', workspace_id: 'ws-abc-123', started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -304,9 +306,12 @@ describe('MemoryRepository', () => { expect(result.studioId).toBe('ws-abc-123'); expect(result.workspaceId).toBe('ws-abc-123'); - // Verify insert was called with workspace_id + // Verify insert was called with both studio_id (new) and workspace_id (legacy). expect(mockSupabase._queryBuilder.insert).toHaveBeenCalledWith( - expect.objectContaining({ workspace_id: 'ws-abc-123' }), + expect.objectContaining({ + studio_id: 'ws-abc-123', + workspace_id: 'ws-abc-123', + }), ); }); @@ -315,6 +320,7 @@ describe('MemoryRepository', () => { id: 'session-studio-wins', user_id: 'user-456', agent_id: 'wren', + studio_id: 'studio-abc', workspace_id: 'studio-abc', started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -332,15 +338,19 @@ describe('MemoryRepository', () => { }); expect(mockSupabase._queryBuilder.insert).toHaveBeenCalledWith( - expect.objectContaining({ workspace_id: 'studio-abc' }), + expect.objectContaining({ + studio_id: 'studio-abc', + workspace_id: 'studio-abc', + }), ); }); - it('should not include workspace_id in insert when workspaceId is omitted', async () => { + it('should not include studio/workspace IDs in insert when studioId is omitted', async () => { const mockSessionRow = { id: 'session-no-ws', user_id: 'user-456', agent_id: 'wren', + studio_id: null, workspace_id: null, started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -355,8 +365,9 @@ describe('MemoryRepository', () => { agentId: 'wren', }); - // Verify insert was called WITHOUT workspace_id key + // Verify insert was called WITHOUT studio/workspace keys const insertCall = mockSupabase._queryBuilder.insert.mock.calls[0][0]; + expect(insertCall).not.toHaveProperty('studio_id'); expect(insertCall).not.toHaveProperty('workspace_id'); }); }); @@ -367,6 +378,7 @@ describe('MemoryRepository', () => { id: 'session-123', user_id: 'user-456', agent_id: 'claude-code', + studio_id: null, workspace_id: null, started_at: '2026-01-26T12:00:00Z', ended_at: '2026-01-26T14:00:00Z', @@ -389,6 +401,7 @@ describe('MemoryRepository', () => { id: 'session-123', user_id: 'user-456', agent_id: 'claude-code', + studio_id: null, workspace_id: null, started_at: '2026-01-26T12:00:00Z', ended_at: null, @@ -413,11 +426,12 @@ describe('MemoryRepository', () => { expect(result).toBeNull(); }); - it('should not filter by workspace when workspaceId is undefined (backward compat)', async () => { + it('should not filter by studio when studioId is undefined (backward compat)', async () => { const mockSessionRow = { id: 'session-any-ws', user_id: 'user-456', agent_id: 'wren', + studio_id: 'ws-something', workspace_id: 'ws-something', started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -430,22 +444,23 @@ describe('MemoryRepository', () => { const result = await repo.getActiveSession('user-456', 'wren'); expect(result).not.toBeNull(); - // workspace_id should not have been used as a filter - // eq should have been called for user_id and agent_id but NOT workspace_id + // studio_id should not have been used as a filter + // eq should have been called for user_id and agent_id but NOT studio_id const eqCalls = mockSupabase._queryBuilder.eq.mock.calls; - const wsEqCalls = eqCalls.filter(([col]: [string]) => col === 'workspace_id'); + const wsEqCalls = eqCalls.filter(([col]: [string]) => col === 'studio_id'); expect(wsEqCalls).toHaveLength(0); const isCalls = mockSupabase._queryBuilder.is.mock.calls; - const wsIsCalls = isCalls.filter(([col]: [string]) => col === 'workspace_id'); + const wsIsCalls = isCalls.filter(([col]: [string]) => col === 'studio_id'); expect(wsIsCalls).toHaveLength(0); }); - it('should filter for null workspace when workspaceId is explicitly null', async () => { + it('should filter for null studio when studioId is explicitly null', async () => { const mockSessionRow = { id: 'session-no-ws', user_id: 'user-456', agent_id: 'wren', + studio_id: null, workspace_id: null, started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -457,15 +472,16 @@ describe('MemoryRepository', () => { await repo.getActiveSession('user-456', 'wren', null); - // Should have called is('workspace_id', null) - expect(mockSupabase._queryBuilder.is).toHaveBeenCalledWith('workspace_id', null); + // Should have called is('studio_id', null) + expect(mockSupabase._queryBuilder.is).toHaveBeenCalledWith('studio_id', null); }); - it('should filter for specific workspace when workspaceId is a string', async () => { + it('should filter for specific studio when studioId is a string', async () => { const mockSessionRow = { id: 'session-specific-ws', user_id: 'user-456', agent_id: 'wren', + studio_id: 'ws-xyz', workspace_id: 'ws-xyz', started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -477,18 +493,18 @@ describe('MemoryRepository', () => { await repo.getActiveSession('user-456', 'wren', 'ws-xyz'); - // Should have called eq('workspace_id', 'ws-xyz') - expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('workspace_id', 'ws-xyz'); + // Should have called eq('studio_id', 'ws-xyz') + expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('studio_id', 'ws-xyz'); }); }); describe('listSessions', () => { - it('should filter by workspaceId when provided', async () => { + it('should filter by workspaceId alias when provided', async () => { mockSupabase._setArrayData([]); await repo.listSessions('user-456', { workspaceId: 'ws-filter' }); - expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('workspace_id', 'ws-filter'); + expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('studio_id', 'ws-filter'); }); it('should filter by studioId when provided', async () => { @@ -496,26 +512,27 @@ describe('MemoryRepository', () => { await repo.listSessions('user-456', { studioId: 'studio-filter' }); - expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('workspace_id', 'studio-filter'); + expect(mockSupabase._queryBuilder.eq).toHaveBeenCalledWith('studio_id', 'studio-filter'); }); - it('should not filter by workspace when workspaceId is omitted', async () => { + it('should not filter by studio when workspaceId/studioId are omitted', async () => { mockSupabase._setArrayData([]); await repo.listSessions('user-456', { agentId: 'wren' }); const eqCalls = mockSupabase._queryBuilder.eq.mock.calls; - const wsEqCalls = eqCalls.filter(([col]: [string]) => col === 'workspace_id'); + const wsEqCalls = eqCalls.filter(([col]: [string]) => col === 'studio_id'); expect(wsEqCalls).toHaveLength(0); }); }); describe('rowToSession mapping', () => { - it('should map workspace_id to both studioId and workspaceId', async () => { + it('should map studio_id to both studioId and workspaceId', async () => { const mockSessionRow = { id: 'session-map', user_id: 'user-456', agent_id: 'wren', + studio_id: 'studio-mapped', workspace_id: 'ws-mapped', started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -526,15 +543,36 @@ describe('MemoryRepository', () => { mockSupabase._setReturnData(mockSessionRow); const result = await repo.getSession('session-map'); - expect(result!.studioId).toBe('ws-mapped'); - expect(result!.workspaceId).toBe('ws-mapped'); + expect(result!.studioId).toBe('studio-mapped'); + expect(result!.workspaceId).toBe('studio-mapped'); }); - it('should map null workspace_id to undefined', async () => { + it('should fall back to workspace_id when studio_id is missing', async () => { + const mockSessionRow = { + id: 'session-fallback', + user_id: 'user-456', + agent_id: 'wren', + studio_id: null, + workspace_id: 'legacy-workspace-id', + started_at: '2026-02-10T00:00:00Z', + ended_at: null, + summary: null, + metadata: {}, + }; + + mockSupabase._setReturnData(mockSessionRow); + + const result = await repo.getSession('session-fallback'); + expect(result!.studioId).toBe('legacy-workspace-id'); + expect(result!.workspaceId).toBe('legacy-workspace-id'); + }); + + it('should map null studio/workspace IDs to undefined', async () => { const mockSessionRow = { id: 'session-null-ws', user_id: 'user-456', agent_id: 'wren', + studio_id: null, workspace_id: null, started_at: '2026-02-10T00:00:00Z', ended_at: null, @@ -560,6 +598,7 @@ describe('MemoryRepository', () => { id: 'session-123', user_id: 'user-123', agent_id: 'wren', + studio_id: null, workspace_id: null, current_phase: 'implementing', started_at: '2026-02-10T10:00:00Z', @@ -716,6 +755,7 @@ describe('MemoryRepository', () => { id: 'session-123', user_id: 'user-123', agent_id: 'wren', + studio_id: 'studio-abc', workspace_id: 'workspace-abc', current_phase: 'blocked:awaiting-input', started_at: '2026-02-10T10:00:00Z', @@ -735,6 +775,7 @@ describe('MemoryRepository', () => { id: 'session-123', user_id: 'user-123', agent_id: 'wren', + studio_id: null, workspace_id: null, current_phase: null, started_at: '2026-02-10T10:00:00Z', diff --git a/packages/api/src/data/repositories/memory-repository.ts b/packages/api/src/data/repositories/memory-repository.ts index a5441956..3cfca1a1 100644 --- a/packages/api/src/data/repositories/memory-repository.ts +++ b/packages/api/src/data/repositories/memory-repository.ts @@ -198,6 +198,8 @@ export class MemoryRepository { }; const scopedStudioId = input.studioId ?? input.workspaceId; if (scopedStudioId !== undefined) { + insertData.studio_id = scopedStudioId; + // Backward compatibility for older server versions still reading workspace_id. insertData.workspace_id = scopedStudioId; } @@ -307,10 +309,10 @@ export class MemoryRepository { /** * Get active session for a user (most recent without ended_at). * - * studioId/workspaceId behavior: - * - undefined: don't filter by studio/workspace (backward compat — finds any active session) - * - null: match sessions with no studio/workspace - * - string: match that specific studio/workspace + * studioId behavior: + * - undefined: don't filter by studio (find any active session) + * - null: match sessions with no studio + * - string: match that specific studio */ async getActiveSession(userId: string, agentId?: string, studioId?: string | null): Promise { let query = this.supabase @@ -327,9 +329,9 @@ export class MemoryRepository { if (studioId !== undefined) { if (studioId === null) { - query = query.is('workspace_id', null); + query = query.is('studio_id', null); } else { - query = query.eq('workspace_id', studioId); + query = query.eq('studio_id', studioId); } } @@ -389,7 +391,7 @@ export class MemoryRepository { const scopedStudioId = options.studioId ?? options.workspaceId; if (scopedStudioId) { - query = query.eq('workspace_id', scopedStudioId); + query = query.eq('studio_id', scopedStudioId); } const limit = options.limit || 20; @@ -693,7 +695,7 @@ export class MemoryRepository { } private rowToSession(row: SessionRow): Session { - const studioId = row.workspace_id || undefined; + const studioId = row.studio_id || row.workspace_id || undefined; return { id: row.id, userId: row.user_id, diff --git a/supabase/migrations/018_add_studio_id_to_sessions.sql b/supabase/migrations/018_add_studio_id_to_sessions.sql new file mode 100644 index 00000000..f1e1e2bb --- /dev/null +++ b/supabase/migrations/018_add_studio_id_to_sessions.sql @@ -0,0 +1,57 @@ +-- Add studio_id to sessions while keeping workspace_id for backward compatibility. +-- +-- Why: +-- - "workspace" now refers to product-level containers (personal/team). +-- - Existing session scoping field workspace_id actually represents a git worktree studio. +-- +-- Compatibility goals: +-- - New server versions read/write studio_id. +-- - Older server versions still writing workspace_id continue to work. +-- - Both columns are kept synchronized via trigger. + +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS studio_id UUID REFERENCES workspaces(id) ON DELETE SET NULL; + +-- Backfill existing data in both directions for safety. +UPDATE sessions +SET studio_id = workspace_id +WHERE studio_id IS NULL + AND workspace_id IS NOT NULL; + +UPDATE sessions +SET workspace_id = studio_id +WHERE workspace_id IS NULL + AND studio_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_sessions_studio_id ON sessions(studio_id); +CREATE INDEX IF NOT EXISTS idx_sessions_active_studio_lookup + ON sessions(user_id, agent_id, studio_id) + WHERE ended_at IS NULL; + +CREATE OR REPLACE FUNCTION sync_sessions_studio_workspace_ids() +RETURNS TRIGGER AS $$ +BEGIN + -- Old writers set workspace_id only. + IF NEW.studio_id IS NULL AND NEW.workspace_id IS NOT NULL THEN + NEW.studio_id := NEW.workspace_id; + + -- New writers set studio_id only. + ELSIF NEW.workspace_id IS NULL AND NEW.studio_id IS NOT NULL THEN + NEW.workspace_id := NEW.studio_id; + + -- Divergence safety: studio_id is the source of truth. + ELSIF NEW.studio_id IS NOT NULL + AND NEW.workspace_id IS NOT NULL + AND NEW.studio_id <> NEW.workspace_id THEN + NEW.workspace_id := NEW.studio_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS sync_sessions_studio_workspace_ids_trigger ON sessions; +CREATE TRIGGER sync_sessions_studio_workspace_ids_trigger + BEFORE INSERT OR UPDATE ON sessions + FOR EACH ROW + EXECUTE FUNCTION sync_sessions_studio_workspace_ids(); From 68867c0f6af43e7ef26f7502fea5f99fdfa79743 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 22:56:03 -0800 Subject: [PATCH 04/10] feat(api): scope admin and identity data to workspace containers --- packages/api/src/data/supabase/types.ts | 120 ++++++ .../api/src/mcp/tools/artifact-handlers.ts | 127 ++++--- .../api/src/mcp/tools/identity-handlers.ts | 84 +++-- .../src/mcp/tools/user-identity-handlers.ts | 50 ++- .../mcp/tools/workspace-container-handlers.ts | 10 +- packages/api/src/routes/admin.ts | 348 ++++++++++-------- packages/api/src/services/authorization.ts | 113 ++++-- packages/api/src/services/oauth.ts | 167 ++++++--- packages/api/src/utils/request-context.ts | 20 +- ...ope_admin_data_to_workspace_containers.sql | 260 +++++++++++++ 10 files changed, 992 insertions(+), 307 deletions(-) create mode 100644 supabase/migrations/019_scope_admin_data_to_workspace_containers.sql diff --git a/packages/api/src/data/supabase/types.ts b/packages/api/src/data/supabase/types.ts index 1219a0cf..ca667bab 100644 --- a/packages/api/src/data/supabase/types.ts +++ b/packages/api/src/data/supabase/types.ts @@ -136,6 +136,7 @@ export type Database = { user_id: string values: Json | null version: number | null + workspace_id: string | null } Insert: { agent_id: string @@ -154,6 +155,7 @@ export type Database = { user_id: string values?: Json | null version?: number | null + workspace_id?: string | null } Update: { agent_id?: string @@ -172,6 +174,7 @@ export type Database = { user_id?: string values?: Json | null version?: number | null + workspace_id?: string | null } Relationships: [ { @@ -181,6 +184,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "agent_identities_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } agent_identity_history: { @@ -203,6 +213,7 @@ export type Database = { user_id: string values: Json | null version: number + workspace_id: string | null } Insert: { agent_id: string @@ -223,6 +234,7 @@ export type Database = { user_id: string values?: Json | null version: number + workspace_id?: string | null } Update: { agent_id?: string @@ -243,6 +255,7 @@ export type Database = { user_id?: string values?: Json | null version?: number + workspace_id?: string | null } Relationships: [ { @@ -252,6 +265,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "agent_identity_history_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } agent_inbox: { @@ -417,6 +437,7 @@ export type Database = { id: string title: string version: number + workspace_id: string | null } Insert: { artifact_id: string @@ -430,6 +451,7 @@ export type Database = { id?: string title: string version: number + workspace_id?: string | null } Update: { artifact_id?: string @@ -443,6 +465,7 @@ export type Database = { id?: string title?: string version?: number + workspace_id?: string | null } Relationships: [ { @@ -466,6 +489,13 @@ export type Database = { referencedRelation: "agent_identities" referencedColumns: ["id"] }, + { + foreignKeyName: "artifact_history_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } artifact_comments: { @@ -481,6 +511,7 @@ export type Database = { parent_comment_id: string | null updated_at: string | null user_id: string + workspace_id: string | null } Insert: { artifact_id: string @@ -494,6 +525,7 @@ export type Database = { parent_comment_id?: string | null updated_at?: string | null user_id: string + workspace_id?: string | null } Update: { artifact_id?: string @@ -507,6 +539,7 @@ export type Database = { parent_comment_id?: string | null updated_at?: string | null user_id?: string + workspace_id?: string | null } Relationships: [ { @@ -537,6 +570,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "artifact_comments_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } artifacts: { @@ -557,6 +597,7 @@ export type Database = { user_id: string version: number | null visibility: string | null + workspace_id: string | null } Insert: { artifact_type?: string @@ -575,6 +616,7 @@ export type Database = { user_id: string version?: number | null visibility?: string | null + workspace_id?: string | null } Update: { artifact_type?: string @@ -593,6 +635,7 @@ export type Database = { user_id?: string version?: number | null visibility?: string | null + workspace_id?: string | null } Relationships: [ { @@ -609,6 +652,13 @@ export type Database = { referencedRelation: "agent_identities" referencedColumns: ["id"] }, + { + foreignKeyName: "artifacts_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } audit_log: { @@ -685,6 +735,7 @@ export type Database = { revoked_at: string | null revoked_by: string | null status: string + workspace_id: string | null } Insert: { authorization_method?: string | null @@ -697,6 +748,7 @@ export type Database = { revoked_at?: string | null revoked_by?: string | null status?: string + workspace_id?: string | null } Update: { authorization_method?: string | null @@ -709,6 +761,7 @@ export type Database = { revoked_at?: string | null revoked_by?: string | null status?: string + workspace_id?: string | null } Relationships: [ { @@ -725,6 +778,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "authorized_groups_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } connected_accounts: { @@ -748,6 +808,7 @@ export type Database = { token_type: string | null updated_at: string | null user_id: string + workspace_id: string | null } Insert: { access_token: string @@ -769,6 +830,7 @@ export type Database = { token_type?: string | null updated_at?: string | null user_id: string + workspace_id?: string | null } Update: { access_token?: string @@ -790,6 +852,7 @@ export type Database = { token_type?: string | null updated_at?: string | null user_id?: string + workspace_id?: string | null } Relationships: [ { @@ -799,6 +862,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "connected_accounts_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } contacts: { @@ -1011,6 +1081,7 @@ export type Database = { used_at: string | null used_for_group_id: string | null used_for_platform: string | null + workspace_id: string | null } Insert: { code: string @@ -1021,6 +1092,7 @@ export type Database = { used_at?: string | null used_for_group_id?: string | null used_for_platform?: string | null + workspace_id?: string | null } Update: { code?: string @@ -1031,6 +1103,7 @@ export type Database = { used_at?: string | null used_for_group_id?: string | null used_for_platform?: string | null + workspace_id?: string | null } Relationships: [ { @@ -1040,6 +1113,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "group_challenge_codes_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } heartbeat_state: { @@ -1883,6 +1963,7 @@ export type Database = { model: string | null started_at: string | null status: string | null + studio_id: string | null summary: string | null token_count: number | null updated_at: string | null @@ -1905,6 +1986,7 @@ export type Database = { model?: string | null started_at?: string | null status?: string | null + studio_id?: string | null summary?: string | null token_count?: number | null updated_at?: string | null @@ -1927,6 +2009,7 @@ export type Database = { model?: string | null started_at?: string | null status?: string | null + studio_id?: string | null summary?: string | null token_count?: number | null updated_at?: string | null @@ -1942,6 +2025,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "sessions_studio_id_fkey" + columns: ["studio_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, { foreignKeyName: "sessions_workspace_id_fkey" columns: ["workspace_id"] @@ -2255,6 +2345,7 @@ export type Database = { platform_user_id: string trust_level: Database["public"]["Enums"]["trust_level"] user_id: string | null + workspace_id: string | null } Insert: { added_at?: string | null @@ -2264,6 +2355,7 @@ export type Database = { platform_user_id: string trust_level?: Database["public"]["Enums"]["trust_level"] user_id?: string | null + workspace_id?: string | null } Update: { added_at?: string | null @@ -2273,6 +2365,7 @@ export type Database = { platform_user_id?: string trust_level?: Database["public"]["Enums"]["trust_level"] user_id?: string | null + workspace_id?: string | null } Relationships: [ { @@ -2289,6 +2382,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "trusted_users_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } user_identity: { @@ -2301,6 +2401,7 @@ export type Database = { user_id: string user_profile_md: string | null version: number | null + workspace_id: string | null } Insert: { created_at?: string | null @@ -2311,6 +2412,7 @@ export type Database = { user_id: string user_profile_md?: string | null version?: number | null + workspace_id?: string | null } Update: { created_at?: string | null @@ -2321,6 +2423,7 @@ export type Database = { user_id?: string user_profile_md?: string | null version?: number | null + workspace_id?: string | null } Relationships: [ { @@ -2330,6 +2433,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "user_identity_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } user_identity_history: { @@ -2344,6 +2454,7 @@ export type Database = { user_id: string user_profile_md: string | null version: number + workspace_id: string | null } Insert: { archived_at?: string | null @@ -2356,6 +2467,7 @@ export type Database = { user_id: string user_profile_md?: string | null version: number + workspace_id?: string | null } Update: { archived_at?: string | null @@ -2368,6 +2480,7 @@ export type Database = { user_id?: string user_profile_md?: string | null version?: number + workspace_id?: string | null } Relationships: [ { @@ -2377,6 +2490,13 @@ export type Database = { referencedRelation: "users" referencedColumns: ["id"] }, + { + foreignKeyName: "user_identity_history_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspace_containers" + referencedColumns: ["id"] + }, ] } user_permissions: { diff --git a/packages/api/src/mcp/tools/artifact-handlers.ts b/packages/api/src/mcp/tools/artifact-handlers.ts index 99bbb155..abf49576 100644 --- a/packages/api/src/mcp/tools/artifact-handlers.ts +++ b/packages/api/src/mcp/tools/artifact-handlers.ts @@ -15,7 +15,11 @@ import type { Database, Json } from '../../data/supabase/types'; // ============== Schemas ============== -const createArtifactSchema = userIdentifierBaseSchema.extend({ +const workspaceScopedUserIdentifierSchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), +}); + +const createArtifactSchema = workspaceScopedUserIdentifierSchema.extend({ uri: z.string().describe('Unique URI for the artifact (e.g., "pcp://specs/orchestration")'), title: z.string().describe('Title of the artifact'), content: z.string().describe('Content (typically markdown)'), @@ -35,12 +39,12 @@ const createArtifactSchema = userIdentifierBaseSchema.extend({ metadata: z.record(z.unknown()).optional().describe('Additional metadata'), }); -const getArtifactSchema = userIdentifierBaseSchema.extend({ +const getArtifactSchema = workspaceScopedUserIdentifierSchema.extend({ uri: z.string().optional().describe('URI of the artifact'), artifactId: z.string().uuid().optional().describe('ID of the artifact'), }); -const updateArtifactSchema = userIdentifierBaseSchema.extend({ +const updateArtifactSchema = workspaceScopedUserIdentifierSchema.extend({ uri: z.string().optional().describe('URI of the artifact to update'), artifactId: z.string().uuid().optional().describe('ID of the artifact to update'), title: z.string().optional().describe('New title'), @@ -52,7 +56,7 @@ const updateArtifactSchema = userIdentifierBaseSchema.extend({ changeSummary: z.string().optional().describe('Summary of changes'), }); -const listArtifactsSchema = userIdentifierBaseSchema.extend({ +const listArtifactsSchema = workspaceScopedUserIdentifierSchema.extend({ artifactType: z.string().optional().describe('Filter by type'), tags: z.array(z.string()).optional().describe('Filter by tags (any match)'), visibility: z.enum(['private', 'shared', 'public']).optional().describe('Filter by visibility'), @@ -60,13 +64,13 @@ const listArtifactsSchema = userIdentifierBaseSchema.extend({ limit: z.number().min(1).max(100).optional().default(20).describe('Max results'), }); -const getArtifactHistorySchema = userIdentifierBaseSchema.extend({ +const getArtifactHistorySchema = workspaceScopedUserIdentifierSchema.extend({ uri: z.string().optional().describe('URI of the artifact'), artifactId: z.string().uuid().optional().describe('ID of the artifact'), limit: z.number().min(1).max(50).optional().default(10).describe('Max history entries'), }); -const addArtifactCommentSchema = userIdentifierBaseSchema.extend({ +const addArtifactCommentSchema = workspaceScopedUserIdentifierSchema.extend({ uri: z.string().optional().describe('URI of the artifact'), artifactId: z.string().uuid().optional().describe('ID of the artifact'), content: z.string().min(1).describe('Comment text'), @@ -75,7 +79,7 @@ const addArtifactCommentSchema = userIdentifierBaseSchema.extend({ metadata: z.record(z.unknown()).optional().describe('Additional metadata'), }); -const listArtifactCommentsSchema = userIdentifierBaseSchema.extend({ +const listArtifactCommentsSchema = workspaceScopedUserIdentifierSchema.extend({ uri: z.string().optional().describe('URI of the artifact'), artifactId: z.string().uuid().optional().describe('ID of the artifact'), limit: z.number().min(1).max(200).optional().default(100).describe('Max comments to return'), @@ -83,19 +87,27 @@ const listArtifactCommentsSchema = userIdentifierBaseSchema.extend({ // ============== Helpers ============== +function withWorkspaceFilter(query: T, workspaceId?: string): T { + if (!workspaceId) return query; + return (query as { eq: (column: string, value: string) => T }).eq('workspace_id', workspaceId); +} + async function resolveIdentityForAgent( supabase: SupabaseClient, userId: string, + workspaceId: string | undefined, agentId?: string ) { if (!agentId) return null; - const { data, error } = await supabase + let query = supabase .from('agent_identities') .select('id, agent_id, name, backend') .eq('user_id', userId) - .eq('agent_id', agentId) - .maybeSingle(); + .eq('agent_id', agentId); + + query = withWorkspaceFilter(query, workspaceId); + const { data, error } = await query.maybeSingle(); if (error) { logger.warn('Failed to resolve identity UUID for agent slug; continuing with slug-only reference', { @@ -120,6 +132,7 @@ async function resolveIdentityForAgent( function resolveArtifactForUser( supabase: SupabaseClient, userId: string, + workspaceId: string | undefined, params: { uri?: string; artifactId?: string }, selectColumns = '*' ) { @@ -129,6 +142,7 @@ function resolveArtifactForUser( } let query = supabase.from('artifacts').select(selectColumns).eq('user_id', userId); + query = withWorkspaceFilter(query, workspaceId); if (uri) { query = query.eq('uri', uri); @@ -159,15 +173,18 @@ export async function handleCreateArtifact( visibility = 'private', tags = [], metadata = {}, + workspaceId, } = parsed; - const authorIdentity = await resolveIdentityForAgent(supabase, resolved.user.id, agentId); + const authorIdentity = await resolveIdentityForAgent(supabase, resolved.user.id, workspaceId, agentId); // Check if URI already exists - const { data: existing } = await supabase + let existingQuery = supabase .from('artifacts') .select('id') - .eq('uri', uri) - .maybeSingle(); + .eq('user_id', resolved.user.id) + .eq('uri', uri); + existingQuery = withWorkspaceFilter(existingQuery, workspaceId); + const { data: existing } = await existingQuery.maybeSingle(); if (existing) { throw new Error(`Artifact with URI "${uri}" already exists`); @@ -178,6 +195,7 @@ export async function handleCreateArtifact( .insert({ uri, user_id: resolved.user.id, + ...(workspaceId ? { workspace_id: workspaceId } : {}), created_by_agent_id: agentId || null, created_by_identity_id: authorIdentity?.id || null, title, @@ -200,6 +218,7 @@ export async function handleCreateArtifact( // Create initial history entry await supabase.from('artifact_history').insert({ artifact_id: artifact.id, + ...(workspaceId ? { workspace_id: workspaceId } : {}), version: 1, title, content, @@ -241,8 +260,8 @@ export async function handleGetArtifact( const parsed = getArtifactSchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { uri, artifactId } = parsed; - const query = resolveArtifactForUser(supabase, resolved.user.id, { uri, artifactId }); + const { uri, artifactId, workspaceId } = parsed; + const query = resolveArtifactForUser(supabase, resolved.user.id, workspaceId, { uri, artifactId }); const { data: artifact, error } = await query.maybeSingle(); @@ -302,11 +321,22 @@ export async function handleUpdateArtifact( const parsed = updateArtifactSchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { uri, artifactId, title, content, baseVersion, agentId, collaborators, tags, changeSummary } = parsed; - const editorIdentity = await resolveIdentityForAgent(supabase, resolved.user.id, agentId); + const { + uri, + artifactId, + title, + content, + baseVersion, + agentId, + collaborators, + tags, + changeSummary, + workspaceId, + } = parsed; + const editorIdentity = await resolveIdentityForAgent(supabase, resolved.user.id, workspaceId, agentId); // First, get the current artifact - const query = resolveArtifactForUser(supabase, resolved.user.id, { uri, artifactId }); + const query = resolveArtifactForUser(supabase, resolved.user.id, workspaceId, { uri, artifactId }); const { data: current, error: fetchError } = await query.single(); @@ -335,12 +365,13 @@ export async function handleUpdateArtifact( }); // Fetch the base version content from history - const { data: baseHistory, error: historyError } = await supabase + let baseHistoryQuery = supabase .from('artifact_history') .select('content') .eq('artifact_id', current.id) - .eq('version', baseVersion) - .single(); + .eq('version', baseVersion); + baseHistoryQuery = withWorkspaceFilter(baseHistoryQuery, workspaceId); + const { data: baseHistory, error: historyError } = await baseHistoryQuery.single(); if (historyError || !baseHistory?.content) { throw new Error( @@ -434,13 +465,13 @@ export async function handleUpdateArtifact( // merge check but then one silently overwrites the other. const expectedVersion = current.version ?? 0; - const { data: updated, error: updateError } = await supabase + let updateQuery = supabase .from('artifacts') .update(updates) .eq('id', current.id) - .eq('version', expectedVersion) - .select() - .maybeSingle(); + .eq('version', expectedVersion); + updateQuery = withWorkspaceFilter(updateQuery, workspaceId); + const { data: updated, error: updateError } = await updateQuery.select().maybeSingle(); if (updateError) { throw new Error(`Failed to update artifact: ${updateError.message}`); @@ -477,6 +508,7 @@ export async function handleUpdateArtifact( await supabase.from('artifact_history').insert({ artifact_id: current.id, + ...(workspaceId ? { workspace_id: workspaceId } : {}), version: newVersion, title: updated.title, content: updated.content, @@ -520,14 +552,14 @@ export async function handleListArtifacts( const parsed = listArtifactsSchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { artifactType, tags, visibility, search, limit = 20 } = parsed; + const { artifactType, tags, visibility, search, limit = 20, workspaceId } = parsed; let query = supabase .from('artifacts') .select('id, uri, title, artifact_type, visibility, version, tags, created_at, updated_at') - .eq('user_id', resolved.user.id) - .order('updated_at', { ascending: false }) - .limit(limit); + .eq('user_id', resolved.user.id); + query = withWorkspaceFilter(query, workspaceId); + query = query.order('updated_at', { ascending: false }).limit(limit); if (artifactType) { query = query.eq('artifact_type', artifactType); @@ -580,12 +612,13 @@ export async function handleGetArtifactHistory( const parsed = getArtifactHistorySchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { uri, artifactId, limit = 10 } = parsed; + const { uri, artifactId, limit = 10, workspaceId } = parsed; // First get the artifact to verify ownership const artifactQuery = resolveArtifactForUser( supabase, resolved.user.id, + workspaceId, { uri, artifactId }, 'id' ); @@ -596,10 +629,12 @@ export async function handleGetArtifactHistory( throw new Error(`Artifact not found: ${uri || artifactId}`); } - const { data: history, error } = await supabase + let historyQuery = supabase .from('artifact_history') .select('*') - .eq('artifact_id', artifact.id) + .eq('artifact_id', artifact.id); + historyQuery = withWorkspaceFilter(historyQuery, workspaceId); + const { data: history, error } = await historyQuery .order('version', { ascending: false }) .limit(limit); @@ -640,7 +675,7 @@ export async function handleAddArtifactComment( const parsed = addArtifactCommentSchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { uri, artifactId, content, agentId, parentCommentId, metadata = {} } = parsed; + const { uri, artifactId, content, agentId, parentCommentId, metadata = {}, workspaceId } = parsed; const trimmed = content.trim(); if (!trimmed) { throw new Error('Comment content cannot be empty'); @@ -649,6 +684,7 @@ export async function handleAddArtifactComment( const { data: artifact, error: artifactError } = await resolveArtifactForUser( supabase, resolved.user.id, + workspaceId, { uri, artifactId } ).single(); @@ -657,26 +693,28 @@ export async function handleAddArtifactComment( } if (parentCommentId) { - const { data: parent, error: parentError } = await supabase + let parentQuery = supabase .from('artifact_comments') .select('id') .eq('id', parentCommentId) .eq('artifact_id', artifact.id) - .eq('user_id', resolved.user.id) - .maybeSingle(); + .eq('user_id', resolved.user.id); + parentQuery = withWorkspaceFilter(parentQuery, workspaceId); + const { data: parent, error: parentError } = await parentQuery.maybeSingle(); if (parentError || !parent) { throw new Error(`Parent comment not found: ${parentCommentId}`); } } - const authorIdentity = await resolveIdentityForAgent(supabase, resolved.user.id, agentId); + const authorIdentity = await resolveIdentityForAgent(supabase, resolved.user.id, workspaceId, agentId); const { data: created, error: createError } = await supabase .from('artifact_comments') .insert({ artifact_id: artifact.id, user_id: resolved.user.id, + ...(workspaceId ? { workspace_id: workspaceId } : {}), created_by_agent_id: agentId || null, created_by_identity_id: authorIdentity?.id || null, parent_comment_id: parentCommentId || null, @@ -737,11 +775,12 @@ export async function handleListArtifactComments( const parsed = listArtifactCommentsSchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { uri, artifactId, limit = 100 } = parsed; + const { uri, artifactId, limit = 100, workspaceId } = parsed; const { data: artifact, error: artifactError } = await resolveArtifactForUser( supabase, resolved.user.id, + workspaceId, { uri, artifactId } ).single(); @@ -749,12 +788,14 @@ export async function handleListArtifactComments( throw new Error(`Artifact not found: ${uri || artifactId}`); } - const { data: comments, error } = await supabase + let commentsQuery = supabase .from('artifact_comments') .select('*') .eq('artifact_id', artifact.id) .eq('user_id', resolved.user.id) - .is('deleted_at', null) + .is('deleted_at', null); + commentsQuery = withWorkspaceFilter(commentsQuery, workspaceId); + const { data: comments, error } = await commentsQuery .order('created_at', { ascending: true }) .limit(limit); @@ -768,10 +809,12 @@ export async function handleListArtifactComments( let identitiesById = new Map(); if (identityIds.length > 0) { - const { data: identities, error: identitiesError } = await supabase + let identitiesQuery = supabase .from('agent_identities') .select('id, agent_id, name, backend') .in('id', identityIds); + identitiesQuery = withWorkspaceFilter(identitiesQuery, workspaceId); + const { data: identities, error: identitiesError } = await identitiesQuery; if (identitiesError) { throw new Error(`Failed to resolve comment identities: ${identitiesError.message}`); diff --git a/packages/api/src/mcp/tools/identity-handlers.ts b/packages/api/src/mcp/tools/identity-handlers.ts index daec0b96..b5d2623a 100644 --- a/packages/api/src/mcp/tools/identity-handlers.ts +++ b/packages/api/src/mcp/tools/identity-handlers.ts @@ -18,6 +18,7 @@ import { userIdentifierBaseSchema, resolveUserOrThrow } from '../../services/use // ===================================================== export const saveIdentitySchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), agentId: z.string().describe('Unique identifier for the AI being (e.g., "wren", "benson", "myra")'), name: z.string().describe('Display name for the agent'), role: z.string().describe('Role description (e.g., "Development collaborator via Claude Code")'), @@ -32,20 +33,25 @@ export const saveIdentitySchema = userIdentifierBaseSchema.extend({ }); export const getIdentitySchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), agentId: z.string().describe('Agent identifier to look up'), file: z.enum(['heartbeat', 'soul', 'values', 'identity']) .optional() .describe('Fetch a single identity document to minimize token usage. Omit to get everything.'), }); -export const listIdentitiesSchema = userIdentifierBaseSchema.extend({}); +export const listIdentitiesSchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), +}); export const getIdentityHistorySchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), agentId: z.string().describe('Agent identifier to get history for'), limit: z.number().min(1).max(50).optional().describe('Max history entries (default: 10)'), }); export const restoreIdentitySchema = userIdentifierBaseSchema.extend({ + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), agentId: z.string().describe('Agent identifier to restore'), version: z.number().describe('Version number to restore to'), }); @@ -120,6 +126,11 @@ function generateIdentityMarkdown(identity: { return lines.join('\n'); } +function withWorkspaceFilter(query: T, workspaceId?: string): T { + if (!workspaceId) return query; + return (query as { eq: (column: string, value: string) => T }).eq('workspace_id', workspaceId); +} + /** * Write identity to file system */ @@ -150,15 +161,30 @@ export async function handleSaveIdentity( const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const supabase = dataComposer.getClient(); - const { agentId, name, role, description, values, relationships, capabilities, metadata, heartbeat, soul, syncToFile } = params; + const { + agentId, + name, + role, + description, + values, + relationships, + capabilities, + metadata, + heartbeat, + soul, + syncToFile, + workspaceId, + } = params; // Fetch existing record so omitted optional fields are preserved - const { data: existing } = await supabase + const { data: existing } = await withWorkspaceFilter( + supabase .from('agent_identities') .select('*') .eq('user_id', user.id) - .eq('agent_id', agentId) - .single(); + .eq('agent_id', agentId), + workspaceId, + ).single(); // Build upsert object, preserving existing values for omitted fields const upsertData: TablesInsert<'agent_identities'> = { @@ -173,6 +199,7 @@ export async function handleSaveIdentity( metadata: (metadata !== undefined ? metadata : (existing?.metadata as unknown as Record ?? {})) as unknown as Json, heartbeat: heartbeat !== undefined ? (heartbeat || null) : (existing?.heartbeat ?? null), soul: soul !== undefined ? (soul || null) : (existing?.soul ?? null), + ...(workspaceId ? { workspace_id: workspaceId } : {}), }; // Use upsert to handle both create and update @@ -247,12 +274,14 @@ export async function handleGetIdentity( const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const supabase = dataComposer.getClient(); - const { data, error } = await supabase + let identityQuery = supabase .from('agent_identities') .select('*') .eq('user_id', user.id) - .eq('agent_id', params.agentId) - .single(); + .eq('agent_id', params.agentId); + + identityQuery = withWorkspaceFilter(identityQuery, params.workspaceId); + const { data, error } = await identityQuery.single(); if (error) { if (error.code === 'PGRST116') { @@ -361,11 +390,13 @@ export async function handleListIdentities( const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const supabase = dataComposer.getClient(); - const { data, error } = await supabase + let listQuery = supabase .from('agent_identities') .select('*') - .eq('user_id', user.id) - .order('agent_id'); + .eq('user_id', user.id); + + listQuery = withWorkspaceFilter(listQuery, params.workspaceId); + const { data, error } = await listQuery.order('agent_id'); if (error) { logger.error('Failed to list identities', { error }); @@ -415,12 +446,14 @@ export async function handleGetIdentityHistory( const limit = params.limit || 10; // First get the current identity to get its ID - const { data: current } = await supabase + let currentQuery = supabase .from('agent_identities') .select('id') .eq('user_id', user.id) - .eq('agent_id', params.agentId) - .single(); + .eq('agent_id', params.agentId); + + currentQuery = withWorkspaceFilter(currentQuery, params.workspaceId); + const { data: current } = await currentQuery.single(); if (!current) { return { @@ -443,10 +476,13 @@ export async function handleGetIdentityHistory( } // Get history entries - const { data, error } = await supabase + let historyQuery = supabase .from('agent_identity_history') .select('*') - .eq('identity_id', current.id) + .eq('identity_id', current.id); + + historyQuery = withWorkspaceFilter(historyQuery, params.workspaceId); + const { data, error } = await historyQuery .order('archived_at', { ascending: false }) .limit(limit); @@ -500,24 +536,28 @@ export async function handleRestoreIdentity( const supabase = dataComposer.getClient(); // First get the current identity - const { data: current } = await supabase + let currentQuery = supabase .from('agent_identities') .select('id') .eq('user_id', user.id) - .eq('agent_id', params.agentId) - .single(); + .eq('agent_id', params.agentId); + + currentQuery = withWorkspaceFilter(currentQuery, params.workspaceId); + const { data: current } = await currentQuery.single(); if (!current) { throw new Error(`No identity found for agent: ${params.agentId}`); } // Find the history entry for the requested version - const { data: historyEntry, error: historyError } = await supabase + let restoreQuery = supabase .from('agent_identity_history') .select('*') .eq('identity_id', current.id) - .eq('version', params.version) - .single(); + .eq('version', params.version); + + restoreQuery = withWorkspaceFilter(restoreQuery, params.workspaceId); + const { data: historyEntry, error: historyError } = await restoreQuery.single(); if (historyError || !historyEntry) { throw new Error(`Version ${params.version} not found in history for agent: ${params.agentId}`); diff --git a/packages/api/src/mcp/tools/user-identity-handlers.ts b/packages/api/src/mcp/tools/user-identity-handlers.ts index 70b42972..879dd734 100644 --- a/packages/api/src/mcp/tools/user-identity-handlers.ts +++ b/packages/api/src/mcp/tools/user-identity-handlers.ts @@ -17,6 +17,7 @@ const userIdentifierFields = { phone: z.string().optional().describe('Phone number in E.164 format'), platformId: z.string().optional().describe('Platform-specific user ID'), platform: z.enum(['telegram', 'whatsapp', 'discord']).optional().describe('Platform for user lookup'), + workspaceId: z.string().uuid().optional().describe('Optional product workspace container scope'), }; // ===================================================== @@ -48,6 +49,11 @@ export const restoreUserIdentitySchema = z.object({ // HANDLERS // ===================================================== +function withWorkspaceFilter(query: T, workspaceId?: string): T { + if (!workspaceId) return query; + return (query as { eq: (column: string, value: string) => T }).eq('workspace_id', workspaceId); +} + /** * Save or update user identity (USER.md, VALUES.md) */ @@ -56,13 +62,15 @@ export async function handleSaveUserIdentity(args: unknown, dataComposer: DataCo const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const supabase = dataComposer.getClient(); + const workspaceId = params.workspaceId; // Check if identity already exists - const { data: existing } = await supabase + let existingQuery = supabase .from('user_identity') .select('id, version') - .eq('user_id', user.id) - .single(); + .eq('user_id', user.id); + existingQuery = withWorkspaceFilter(existingQuery, workspaceId); + const { data: existing } = await existingQuery.single(); let result; if (existing) { @@ -93,6 +101,7 @@ export async function handleSaveUserIdentity(args: unknown, dataComposer: DataCo .from('user_identity') .insert({ user_id: user.id, + ...(workspaceId ? { workspace_id: workspaceId } : {}), user_profile_md: params.userProfileMd || null, shared_values_md: params.sharedValuesMd || null, process_md: params.processMd || null, @@ -146,11 +155,12 @@ export async function handleGetUserIdentity(args: unknown, dataComposer: DataCom const supabase = dataComposer.getClient(); - const { data, error } = await supabase + let identityQuery = supabase .from('user_identity') .select('*') - .eq('user_id', user.id) - .single(); + .eq('user_id', user.id); + identityQuery = withWorkspaceFilter(identityQuery, params.workspaceId); + const { data, error } = await identityQuery.single(); if (error && error.code !== 'PGRST116') { throw new Error(`Failed to get user identity: ${error.message}`); @@ -215,17 +225,20 @@ export async function handleGetUserIdentityHistory(args: unknown, dataComposer: const limit = params.limit || 10; // Get current identity - const { data: current } = await supabase + let currentQuery = supabase .from('user_identity') .select('*') - .eq('user_id', user.id) - .single(); + .eq('user_id', user.id); + currentQuery = withWorkspaceFilter(currentQuery, params.workspaceId); + const { data: current } = await currentQuery.single(); // Get history - const { data: history, error } = await supabase + let historyQuery = supabase .from('user_identity_history') .select('*') - .eq('user_id', user.id) + .eq('user_id', user.id); + historyQuery = withWorkspaceFilter(historyQuery, params.workspaceId); + const { data: history, error } = await historyQuery .order('version', { ascending: false }) .limit(limit); @@ -283,28 +296,29 @@ export async function handleRestoreUserIdentity(args: unknown, dataComposer: Dat const supabase = dataComposer.getClient(); // Find the version to restore - const { data: versionToRestore, error: findError } = await supabase + let restoreQuery = supabase .from('user_identity_history') .select('*') .eq('user_id', user.id) - .eq('version', params.version) - .single(); + .eq('version', params.version); + restoreQuery = withWorkspaceFilter(restoreQuery, params.workspaceId); + const { data: versionToRestore, error: findError } = await restoreQuery.single(); if (findError || !versionToRestore) { throw new Error(`Version ${params.version} not found in history`); } // Update current identity with the historical values - const { data: result, error: updateError } = await supabase + let updateQuery = supabase .from('user_identity') .update({ user_profile_md: versionToRestore.user_profile_md, shared_values_md: versionToRestore.shared_values_md, process_md: versionToRestore.process_md, }) - .eq('user_id', user.id) - .select() - .single(); + .eq('user_id', user.id); + updateQuery = withWorkspaceFilter(updateQuery, params.workspaceId); + const { data: result, error: updateError } = await updateQuery.select().single(); if (updateError) { throw new Error(`Failed to restore user identity: ${updateError.message}`); diff --git a/packages/api/src/mcp/tools/workspace-container-handlers.ts b/packages/api/src/mcp/tools/workspace-container-handlers.ts index c8ad01dc..85fbef71 100644 --- a/packages/api/src/mcp/tools/workspace-container-handlers.ts +++ b/packages/api/src/mcp/tools/workspace-container-handlers.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import type { DataComposer } from '../../data/composer'; import { resolveUserOrThrow, userIdentifierBaseSchema } from '../../services/user-resolver'; import type { WorkspaceContainerType } from '../../data/repositories/workspace-containers.repository'; +import type { Json } from '../../data/supabase/types'; const workspaceContainerTypeSchema = z.enum(['personal', 'team']); @@ -57,6 +58,10 @@ function successResponse(data: Record) { }; } +function toJsonObject(value: Record | undefined): Json | undefined { + return value as Json | undefined; +} + export async function handleCreateWorkspaceContainer(args: unknown, dataComposer: DataComposer) { const params = createWorkspaceContainerSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); @@ -67,7 +72,7 @@ export async function handleCreateWorkspaceContainer(args: unknown, dataComposer slug: params.slug || slugify(params.name), type: (params.type || 'personal') as WorkspaceContainerType, description: params.description, - metadata: params.metadata, + metadata: toJsonObject(params.metadata), }); await dataComposer.repositories.workspaceContainers.addMember(workspace.id, user.id, 'owner'); @@ -168,7 +173,7 @@ export async function handleUpdateWorkspaceContainer(args: unknown, dataComposer slug: params.slug, type: params.type as WorkspaceContainerType | undefined, description: params.description, - metadata: params.metadata, + metadata: toJsonObject(params.metadata), archivedAt: params.archived === undefined ? undefined : (params.archived ? new Date().toISOString() : null), }); @@ -188,4 +193,3 @@ export async function handleUpdateWorkspaceContainer(args: unknown, dataComposer }, }); } - diff --git a/packages/api/src/routes/admin.ts b/packages/api/src/routes/admin.ts index a502198f..b6dcf01e 100644 --- a/packages/api/src/routes/admin.ts +++ b/packages/api/src/routes/admin.ts @@ -15,12 +15,19 @@ import { getOAuthService } from '../services/oauth'; import { logger } from '../utils/logger'; import { env } from '../config/env'; import { runWithRequestContext } from '../utils/request-context'; +import { getDataComposer } from '../data/composer'; import crypto from 'crypto'; // WhatsApp listener reference (set via setWhatsAppListener) // eslint-disable-next-line @typescript-eslint/no-explicit-any let whatsAppListener: any = null; +type AdminAuthRequest = Request & { + user: { email?: string | null }; + pcpUserId: string; + pcpWorkspaceId: string; +}; + /** * Set the WhatsApp listener for admin endpoints */ @@ -59,14 +66,6 @@ async function adminAuthMiddleware(req: Request, res: Response, next: NextFuncti return; } - // Check if user email is a trusted user with admin/owner privileges - // For now, we check if the user's email matches the owner - // In production, you'd want to check the trusted_users table - const authService = getAuthorizationService(); - - // Get all trusted users and check if this email has admin+ access - const trustedUsers = await authService.listTrustedUsers(); - // Look up the PCP user by email const { data: pcpUser } = await supabase .from('users') @@ -79,7 +78,28 @@ async function adminAuthMiddleware(req: Request, res: Response, next: NextFuncti return; } - // Check if any of the user's platform IDs are trusted with admin/owner level + // Resolve active workspace container from header (or default to personal). + const dataComposer = await getDataComposer(); + const workspaceRepo = dataComposer.repositories.workspaceContainers; + const requestedWorkspaceId = req.header('x-pcp-workspace-id')?.trim(); + + // Ensure every user always has at least one workspace container. + const personalWorkspace = await workspaceRepo.ensurePersonalWorkspace(pcpUser.id); + + let activeWorkspaceId = personalWorkspace.id; + if (requestedWorkspaceId) { + const requestedWorkspace = await workspaceRepo.findById(requestedWorkspaceId, pcpUser.id); + if (!requestedWorkspace) { + res.status(403).json({ error: 'Workspace not found or not accessible' }); + return; + } + activeWorkspaceId = requestedWorkspace.id; + } + + // Check trusted-user access at the selected workspace scope. + const authService = getAuthorizationService(); + const trustedUsers = await authService.listTrustedUsers(undefined, activeWorkspaceId); + const isTrusted = trustedUsers.some((tu) => { if (tu.trustLevel === 'member') return false; if (tu.userId === pcpUser.id) return true; @@ -93,16 +113,18 @@ async function adminAuthMiddleware(req: Request, res: Response, next: NextFuncti return; } - // Attach user and PCP user ID to request - const authReq = req as Request & { user: typeof user; pcpUserId: string }; + // Attach user + PCP context to request + const authReq = req as Request & { user: typeof user; pcpUserId: string; pcpWorkspaceId: string }; authReq.user = user; authReq.pcpUserId = pcpUser.id; + authReq.pcpWorkspaceId = activeWorkspaceId; // Wrap the rest of the request in context runWithRequestContext( { userId: pcpUser.id, email: user.email || undefined, + workspaceId: activeWorkspaceId, }, () => next() ); @@ -117,6 +139,43 @@ const router = Router(); // Apply auth middleware to all routes router.use(adminAuthMiddleware); +// ============================================================================= +// Workspace Containers +// ============================================================================= + +/** + * GET /api/admin/workspaces + * List workspace containers available to the authenticated user. + */ +router.get('/workspaces', async (req: Request, res: Response) => { + try { + const authReq = req as Request & { pcpUserId: string; pcpWorkspaceId: string }; + const dataComposer = await getDataComposer(); + const workspaceRepo = dataComposer.repositories.workspaceContainers; + + await workspaceRepo.ensurePersonalWorkspace(authReq.pcpUserId); + const workspaces = await workspaceRepo.listByUser(authReq.pcpUserId, { includeArchived: false }); + + res.json({ + currentWorkspaceId: authReq.pcpWorkspaceId, + workspaces: workspaces.map((w) => ({ + id: w.id, + name: w.name, + slug: w.slug, + type: w.type, + description: w.description, + metadata: w.metadata, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + archivedAt: w.archivedAt, + })), + }); + } catch (error) { + logger.error('Failed to list workspace containers:', error); + res.status(500).json({ error: 'Failed to list workspace containers' }); + } +}); + // ============================================================================= // Trusted Users // ============================================================================= @@ -125,18 +184,30 @@ router.use(adminAuthMiddleware); * GET /api/admin/trusted-users * List all trusted users */ -router.get('/trusted-users', async (_req: Request, res: Response) => { +router.get('/trusted-users', async (req: Request, res: Response) => { try { - const authService = getAuthorizationService(); - const users = await authService.listTrustedUsers(); + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; + + const { data: users, error } = await supabase + .from('trusted_users') + .select('*') + .eq('workspace_id', authReq.pcpWorkspaceId) + .order('added_at', { ascending: false }); + + if (error) { + logger.error('Failed to list trusted users:', error); + res.status(500).json({ error: 'Failed to list trusted users' }); + return; + } res.json({ - users: users.map((u) => ({ + users: (users || []).map((u) => ({ id: u.id, platform: u.platform, - platformUserId: u.platformUserId, - trustLevel: u.trustLevel, - addedAt: u.addedAt.toISOString(), + platformUserId: u.platform_user_id, + trustLevel: u.trust_level, + addedAt: u.added_at, })), }); } catch (error) { @@ -152,25 +223,33 @@ router.get('/trusted-users', async (_req: Request, res: Response) => { router.post('/trusted-users', async (req: Request, res: Response) => { try { const { platform, platformUserId, trustLevel } = req.body; + const authReq = req as AdminAuthRequest; if (!platform || !platformUserId) { res.status(400).json({ error: 'platform and platformUserId are required' }); return; } - const authService = getAuthorizationService(); + if (!['telegram', 'whatsapp', 'discord'].includes(platform)) { + res.status(400).json({ error: 'platform must be telegram, whatsapp, or discord' }); + return; + } - // For admin dashboard, we use a system admin identity - // In production, you'd track who added the user - const result = await authService.addTrustedUser( - platform, - platformUserId, - trustLevel || 'member', - platformUserId // Self-add for now - in production use the admin's ID - ); + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const { error } = await supabase + .from('trusted_users') + .insert({ + user_id: null, + platform, + platform_user_id: platformUserId, + trust_level: trustLevel || 'member', + added_by: authReq.pcpUserId, + workspace_id: authReq.pcpWorkspaceId, + }); - if (!result.success) { - res.status(400).json({ error: result.error }); + if (error) { + logger.error('Failed to add trusted user:', error); + res.status(400).json({ error: error.message || 'Failed to add trusted user' }); return; } @@ -188,6 +267,7 @@ router.post('/trusted-users', async (req: Request, res: Response) => { router.delete('/trusted-users/:id', async (req: Request, res: Response) => { try { const { id } = req.params; + const authReq = req as AdminAuthRequest; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); @@ -196,6 +276,7 @@ router.delete('/trusted-users/:id', async (req: Request, res: Response) => { .from('trusted_users') .select('trust_level') .eq('id', id) + .eq('workspace_id', authReq.pcpWorkspaceId) .single(); if (user?.trust_level === 'owner') { @@ -206,7 +287,8 @@ router.delete('/trusted-users/:id', async (req: Request, res: Response) => { const { error } = await supabase .from('trusted_users') .delete() - .eq('id', id); + .eq('id', id) + .eq('workspace_id', authReq.pcpWorkspaceId); if (error) { res.status(500).json({ error: 'Failed to delete user' }); @@ -228,13 +310,15 @@ router.delete('/trusted-users/:id', async (req: Request, res: Response) => { * GET /api/admin/groups * List all authorized groups */ -router.get('/groups', async (_req: Request, res: Response) => { +router.get('/groups', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; const { data, error } = await supabase .from('authorized_groups') .select('*') + .eq('workspace_id', authReq.pcpWorkspaceId) .order('authorized_at', { ascending: false }); if (error) { @@ -266,6 +350,7 @@ router.get('/groups', async (_req: Request, res: Response) => { router.post('/groups/:id/revoke', async (req: Request, res: Response) => { try { const { id } = req.params; + const authReq = req as AdminAuthRequest; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); @@ -274,8 +359,10 @@ router.post('/groups/:id/revoke', async (req: Request, res: Response) => { .update({ status: 'revoked', revoked_at: new Date().toISOString(), + revoked_by: authReq.pcpUserId, }) - .eq('id', id); + .eq('id', id) + .eq('workspace_id', authReq.pcpWorkspaceId); if (error) { res.status(500).json({ error: 'Failed to revoke group' }); @@ -297,13 +384,15 @@ router.post('/groups/:id/revoke', async (req: Request, res: Response) => { * GET /api/admin/challenge-codes * List all challenge codes */ -router.get('/challenge-codes', async (_req: Request, res: Response) => { +router.get('/challenge-codes', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; const { data, error } = await supabase .from('group_challenge_codes') .select('*') + .eq('workspace_id', authReq.pcpWorkspaceId) .order('created_at', { ascending: false }) .limit(50); @@ -333,14 +422,16 @@ router.get('/challenge-codes', async (_req: Request, res: Response) => { * POST /api/admin/challenge-codes * Generate a new challenge code */ -router.post('/challenge-codes', async (_req: Request, res: Response) => { +router.post('/challenge-codes', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; // Check rate limit const { count } = await supabase .from('group_challenge_codes') .select('*', { count: 'exact', head: true }) + .eq('workspace_id', authReq.pcpWorkspaceId) .is('used_at', null) .gt('expires_at', new Date().toISOString()); @@ -356,7 +447,11 @@ router.post('/challenge-codes', async (_req: Request, res: Response) => { const { data, error } = await supabase .from('group_challenge_codes') - .insert({ code }) + .insert({ + code, + created_by: authReq.pcpUserId, + workspace_id: authReq.pcpWorkspaceId, + }) .select() .single(); @@ -542,14 +637,16 @@ router.get('/reminders', async (_req: Request, res: Response) => { * GET /api/admin/user-identity * Get user identity (USER.md, VALUES.md) */ -router.get('/user-identity', async (_req: Request, res: Response) => { +router.get('/user-identity', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; const { data, error } = await supabase .from('user_identity') .select('*') - .limit(1) + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .single(); if (error && error.code !== 'PGRST116') { @@ -584,15 +681,17 @@ router.get('/user-identity', async (_req: Request, res: Response) => { * GET /api/admin/user-identity/history * Get version history for user identity */ -router.get('/user-identity/history', async (_req: Request, res: Response) => { +router.get('/user-identity/history', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; // First get the user identity ID const { data: identity } = await supabase .from('user_identity') .select('id') - .limit(1) + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .single(); if (!identity) { @@ -605,6 +704,7 @@ router.get('/user-identity/history', async (_req: Request, res: Response) => { .from('user_identity_history') .select('*') .eq('identity_id', identity.id) + .eq('workspace_id', authReq.pcpWorkspaceId) .order('archived_at', { ascending: false }) .limit(20); @@ -639,13 +739,16 @@ router.get('/user-identity/history', async (_req: Request, res: Response) => { * GET /api/admin/individuals * List all AI being identities */ -router.get('/individuals', async (_req: Request, res: Response) => { +router.get('/individuals', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; const { data, error } = await supabase .from('agent_identities') .select('*') + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .order('agent_id', { ascending: true }); if (error) { @@ -688,11 +791,14 @@ router.get('/individuals/:agentId/history', async (req: Request, res: Response) try { const { agentId } = req.params; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; // First get the identity ID const { data: identity } = await supabase .from('agent_identities') .select('id') + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .eq('agent_id', agentId) .single(); @@ -706,6 +812,7 @@ router.get('/individuals/:agentId/history', async (req: Request, res: Response) .from('agent_identity_history') .select('*') .eq('identity_id', identity.id) + .eq('workspace_id', authReq.pcpWorkspaceId) .order('archived_at', { ascending: false }) .limit(20); @@ -768,6 +875,7 @@ router.get('/individuals/:agentId/memories/timeline', async (req: Request, res: const { agentId } = req.params; const limit = Math.min(parseInt(req.query.limit as string) || 100, 500); const offset = parseInt(req.query.offset as string) || 0; + const authReq = req as AdminAuthRequest; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); const timeline: TimelineEntry[] = []; @@ -776,6 +884,7 @@ router.get('/individuals/:agentId/memories/timeline', async (req: Request, res: const { data: memories, error: memoriesError } = await supabase .from('memories') .select('*') + .eq('user_id', authReq.pcpUserId) .eq('agent_id', agentId) .order('created_at', { ascending: false }); @@ -803,6 +912,7 @@ router.get('/individuals/:agentId/memories/timeline', async (req: Request, res: const { data: history, error: historyError } = await supabase .from('memory_history') .select('*') + .eq('user_id', authReq.pcpUserId) .order('archived_at', { ascending: false }); if (historyError) { @@ -839,6 +949,7 @@ router.get('/individuals/:agentId/memories/timeline', async (req: Request, res: const { data: sessions, error: sessionsError } = await supabase .from('sessions') .select('id') + .eq('user_id', authReq.pcpUserId) .eq('agent_id', agentId); if (sessionsError) { @@ -897,11 +1008,13 @@ router.get('/individuals/:agentId/memories/:memoryId/history', async (req: Reque try { const { memoryId } = req.params; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); + const authReq = req as AdminAuthRequest; // Get memory history const { data, error } = await supabase .from('memory_history') .select('*') + .eq('user_id', authReq.pcpUserId) .eq('memory_id', memoryId) .order('version', { ascending: false }); @@ -937,7 +1050,12 @@ router.get('/individuals/:agentId/memories/:memoryId/history', async (req: Reque // ============================================================================= // In-memory store for OAuth state (in production, use Redis or similar) -const oauthStateStore = new Map(); +const oauthStateStore = new Map(); /** * GET /api/admin/connected-accounts @@ -945,23 +1063,13 @@ const oauthStateStore = new Map { try { - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user ID from the authenticated user's email - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } + const authReq = req as AdminAuthRequest; const oauthService = getOAuthService(); - const accounts = await oauthService.getConnectedAccounts(pcpUser.id); + const accounts = await oauthService.getConnectedAccounts( + authReq.pcpUserId, + authReq.pcpWorkspaceId, + ); // Get supported providers and their configuration status const providers = oauthService.getSupportedProviders().map((provider) => ({ @@ -1000,33 +1108,20 @@ router.get('/oauth/:provider/authorize', async (req: Request, res: Response) => try { const { provider } = req.params; const oauthService = getOAuthService(); + const authReq = req as AdminAuthRequest; if (!oauthService.isProviderConfigured(provider)) { res.status(400).json({ error: `OAuth not configured for ${provider}` }); return; } - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user ID - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } - // Generate state token const state = crypto.randomBytes(32).toString('hex'); // Store state with user info (expires in 10 minutes) oauthStateStore.set(state, { - userId: pcpUser.id, + userId: authReq.pcpUserId, + workspaceId: authReq.pcpWorkspaceId, provider, expiresAt: Date.now() + 10 * 60 * 1000, }); @@ -1144,7 +1239,13 @@ router.get('/oauth/:provider/callback', async (req: Request, res: Response) => { const userInfo = await oauthService.getUserInfo(provider, tokens.accessToken); // Save connected account - await oauthService.saveConnectedAccount(stateData.userId, provider, tokens, userInfo); + await oauthService.saveConnectedAccount( + stateData.userId, + provider, + tokens, + userInfo, + stateData.workspaceId, + ); sendHtmlResponse(true, `Successfully connected ${userInfo.email || provider} account.`); } catch (error) { @@ -1184,6 +1285,7 @@ router.post('/oauth/:provider/upgrade-scopes', async (req: Request, res: Respons const { provider } = req.params; const { accountId } = req.body; const oauthService = getOAuthService(); + const authReq = req as AdminAuthRequest; if (!oauthService.isProviderConfigured(provider)) { res.status(400).json({ error: `OAuth not configured for ${provider}` }); @@ -1196,26 +1298,14 @@ router.post('/oauth/:provider/upgrade-scopes', async (req: Request, res: Respons } const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } // Get the connected account const { data: account, error: accountError } = await supabase .from('connected_accounts') .select('*') .eq('id', accountId) - .eq('user_id', pcpUser.id) + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .single(); if (accountError || !account) { @@ -1237,7 +1327,8 @@ router.post('/oauth/:provider/upgrade-scopes', async (req: Request, res: Respons // Generate state token const state = crypto.randomUUID(); oauthStateStore.set(state, { - userId: pcpUser.id, + userId: authReq.pcpUserId, + workspaceId: authReq.pcpWorkspaceId, provider, expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes }); @@ -1285,24 +1376,13 @@ router.post('/oauth/:provider/upgrade-scopes', async (req: Request, res: Respons router.get('/artifacts', async (req: Request, res: Response) => { try { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user ID from the authenticated user's email - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } + const authReq = req as AdminAuthRequest; const { data, error } = await supabase .from('artifacts') .select('id, uri, title, artifact_type, visibility, version, tags, created_at, updated_at') - .eq('user_id', pcpUser.id) + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .order('updated_at', { ascending: false }); if (error) { @@ -1338,25 +1418,14 @@ router.get('/artifacts/:id', async (req: Request, res: Response) => { try { const { id } = req.params; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user ID - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } + const authReq = req as AdminAuthRequest; const { data: artifact, error } = await supabase .from('artifacts') .select('*') .eq('id', id) - .eq('user_id', pcpUser.id) + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .single(); if (error || !artifact) { @@ -1397,14 +1466,16 @@ router.get('/artifacts/:id/comments', async (req: Request, res: Response) => { try { const { id } = req.params; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { pcpUserId: string }; + const authReq = req as AdminAuthRequest; const pcpUserId = authReq.pcpUserId; + const workspaceId = authReq.pcpWorkspaceId; const { data: artifact } = await supabase .from('artifacts') .select('id') .eq('id', id) .eq('user_id', pcpUserId) + .eq('workspace_id', workspaceId) .single(); if (!artifact) { @@ -1417,6 +1488,7 @@ router.get('/artifacts/:id/comments', async (req: Request, res: Response) => { .select('*') .eq('artifact_id', id) .eq('user_id', pcpUserId) + .eq('workspace_id', workspaceId) .is('deleted_at', null) .order('created_at', { ascending: true }); @@ -1503,14 +1575,16 @@ router.post('/artifacts/:id/comments', async (req: Request, res: Response) => { } const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { pcpUserId: string }; + const authReq = req as AdminAuthRequest; const pcpUserId = authReq.pcpUserId; + const workspaceId = authReq.pcpWorkspaceId; const { data: artifact } = await supabase .from('artifacts') .select('id') .eq('id', id) .eq('user_id', pcpUserId) + .eq('workspace_id', workspaceId) .single(); if (!artifact) { @@ -1525,6 +1599,7 @@ router.post('/artifacts/:id/comments', async (req: Request, res: Response) => { .eq('id', parentCommentId) .eq('artifact_id', id) .eq('user_id', pcpUserId) + .eq('workspace_id', workspaceId) .single(); if (!parent) { @@ -1539,6 +1614,7 @@ router.post('/artifacts/:id/comments', async (req: Request, res: Response) => { .from('agent_identities') .select('id, agent_id, name, backend') .eq('user_id', pcpUserId) + .eq('workspace_id', workspaceId) .eq('agent_id', agentId) .single(); @@ -1557,6 +1633,7 @@ router.post('/artifacts/:id/comments', async (req: Request, res: Response) => { .insert({ artifact_id: id, user_id: pcpUserId, + workspace_id: workspaceId, created_by_agent_id: agentId || null, created_by_identity_id: identity?.id || null, parent_comment_id: parentCommentId || null, @@ -1607,26 +1684,15 @@ router.get('/artifacts/:id/history', async (req: Request, res: Response) => { try { const { id } = req.params; const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user ID - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } + const authReq = req as AdminAuthRequest; // Verify artifact ownership const { data: artifact } = await supabase .from('artifacts') .select('id') .eq('id', id) - .eq('user_id', pcpUser.id) + .eq('user_id', authReq.pcpUserId) + .eq('workspace_id', authReq.pcpWorkspaceId) .single(); if (!artifact) { @@ -1639,6 +1705,7 @@ router.get('/artifacts/:id/history', async (req: Request, res: Response) => { .from('artifact_history') .select('*') .eq('artifact_id', id) + .eq('workspace_id', authReq.pcpWorkspaceId) .order('version', { ascending: false }); if (error) { @@ -1679,23 +1746,10 @@ router.get('/artifacts/:id/history', async (req: Request, res: Response) => { router.delete('/connected-accounts/:id', async (req: Request, res: Response) => { try { const { id } = req.params; - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); - const authReq = req as Request & { user: { email: string } }; - - // Get the PCP user ID - const { data: pcpUser } = await supabase - .from('users') - .select('id') - .eq('email', authReq.user.email) - .single(); - - if (!pcpUser) { - res.status(404).json({ error: 'User not found' }); - return; - } + const authReq = req as AdminAuthRequest; const oauthService = getOAuthService(); - await oauthService.disconnectAccount(id, pcpUser.id); + await oauthService.disconnectAccount(id, authReq.pcpUserId, authReq.pcpWorkspaceId); res.json({ success: true }); } catch (error) { diff --git a/packages/api/src/services/authorization.ts b/packages/api/src/services/authorization.ts index a8581522..9c23d022 100644 --- a/packages/api/src/services/authorization.ts +++ b/packages/api/src/services/authorization.ts @@ -16,6 +16,7 @@ export type Platform = 'telegram' | 'whatsapp' | 'discord'; interface TrustedUser { id: string; userId: string | null; + workspaceId: string | null; platform: Platform; platformUserId: string; trustLevel: TrustLevel; @@ -25,6 +26,7 @@ interface TrustedUser { interface AuthorizedGroup { id: string; + workspaceId: string | null; platform: Platform; platformGroupId: string; groupName: string | null; @@ -45,13 +47,22 @@ export class AuthorizationService { /** * Check if a user is trusted on a platform */ - async isUserTrusted(platform: Platform, platformUserId: string): Promise { - const { data, error } = await this.supabase + async isUserTrusted( + platform: Platform, + platformUserId: string, + workspaceId?: string + ): Promise { + let query = this.supabase .from('trusted_users') .select('*') .eq('platform', platform) - .eq('platform_user_id', platformUserId) - .single(); + .eq('platform_user_id', platformUserId); + + if (workspaceId) { + query = query.eq('workspace_id', workspaceId); + } + + const { data, error } = await query.single(); if (error || !data) { return null; @@ -60,6 +71,7 @@ export class AuthorizationService { return { id: data.id, userId: data.user_id, + workspaceId: data.workspace_id, platform: data.platform, platformUserId: data.platform_user_id, trustLevel: data.trust_level, @@ -71,14 +83,23 @@ export class AuthorizationService { /** * Check if a group is authorized */ - async isGroupAuthorized(platform: Platform, platformGroupId: string): Promise { - const { data, error } = await this.supabase + async isGroupAuthorized( + platform: Platform, + platformGroupId: string, + workspaceId?: string + ): Promise { + let query = this.supabase .from('authorized_groups') .select('*') .eq('platform', platform) .eq('platform_group_id', platformGroupId) - .eq('status', 'active') - .single(); + .eq('status', 'active'); + + if (workspaceId) { + query = query.eq('workspace_id', workspaceId); + } + + const { data, error } = await query.single(); if (error || !data) { return null; @@ -86,6 +107,7 @@ export class AuthorizationService { return { id: data.id, + workspaceId: data.workspace_id, platform: data.platform, platformGroupId: data.platform_group_id, groupName: data.group_name, @@ -100,22 +122,32 @@ export class AuthorizationService { * Generate a challenge code for group authorization * Only trusted users can generate codes */ - async generateChallengeCode(platform: Platform, platformUserId: string): Promise { + async generateChallengeCode( + platform: Platform, + platformUserId: string, + workspaceId?: string + ): Promise { // Verify user is trusted - const trustedUser = await this.isUserTrusted(platform, platformUserId); + const trustedUser = await this.isUserTrusted(platform, platformUserId, workspaceId); if (!trustedUser) { logger.warn('Non-trusted user attempted to generate challenge code', { platform, platformUserId }); return null; } // Check rate limit: max 5 active codes per user - const { count } = await this.supabase + let countQuery = this.supabase .from('group_challenge_codes') .select('*', { count: 'exact', head: true }) .eq('created_by', trustedUser.userId) .is('used_at', null) .gt('expires_at', new Date().toISOString()); + if (workspaceId) { + countQuery = countQuery.eq('workspace_id', workspaceId); + } + + const { count } = await countQuery; + if (count && count >= 5) { logger.warn('User exceeded challenge code rate limit', { userId: trustedUser.userId }); return null; @@ -129,6 +161,7 @@ export class AuthorizationService { .insert({ code, created_by: trustedUser.userId, + workspace_id: workspaceId || null, }); if (error) { @@ -147,23 +180,29 @@ export class AuthorizationService { platform: Platform, platformGroupId: string, groupName: string | null, - code: string + code: string, + workspaceId?: string ): Promise<{ success: boolean; error?: string }> { // Find valid code - const { data: codeData, error: codeError } = await this.supabase + let codeQuery = this.supabase .from('group_challenge_codes') .select('*') .eq('code', code.toUpperCase()) .is('used_at', null) - .gt('expires_at', new Date().toISOString()) - .single(); + .gt('expires_at', new Date().toISOString()); + + if (workspaceId) { + codeQuery = codeQuery.eq('workspace_id', workspaceId); + } + + const { data: codeData, error: codeError } = await codeQuery.single(); if (codeError || !codeData) { return { success: false, error: 'Invalid or expired code' }; } // Check if group is already authorized - const existing = await this.isGroupAuthorized(platform, platformGroupId); + const existing = await this.isGroupAuthorized(platform, platformGroupId, workspaceId); if (existing) { return { success: false, error: 'Group is already authorized' }; } @@ -187,6 +226,7 @@ export class AuthorizationService { group_name: groupName, authorized_by: codeData.created_by, authorization_method: 'challenge_code', + workspace_id: workspaceId || codeData.workspace_id || null, }); if (groupError) { @@ -205,15 +245,16 @@ export class AuthorizationService { platform: Platform, platformGroupId: string, groupName: string | null, - platformUserId: string + platformUserId: string, + workspaceId?: string ): Promise<{ success: boolean; error?: string }> { - const trustedUser = await this.isUserTrusted(platform, platformUserId); + const trustedUser = await this.isUserTrusted(platform, platformUserId, workspaceId); if (!trustedUser) { return { success: false, error: 'User is not trusted' }; } // Check if already authorized - const existing = await this.isGroupAuthorized(platform, platformGroupId); + const existing = await this.isGroupAuthorized(platform, platformGroupId, workspaceId); if (existing) { return { success: true }; // Already authorized, that's fine } @@ -226,6 +267,7 @@ export class AuthorizationService { group_name: groupName, authorized_by: trustedUser.userId, authorization_method: 'trusted_user', + workspace_id: workspaceId || null, }); if (error) { @@ -244,15 +286,16 @@ export class AuthorizationService { async revokeGroup( platform: Platform, platformGroupId: string, - revokedByPlatformUserId: string + revokedByPlatformUserId: string, + workspaceId?: string ): Promise<{ success: boolean; error?: string }> { // Verify user has permission (owner or admin) - const trustedUser = await this.isUserTrusted(platform, revokedByPlatformUserId); + const trustedUser = await this.isUserTrusted(platform, revokedByPlatformUserId, workspaceId); if (!trustedUser || trustedUser.trustLevel === 'member') { return { success: false, error: 'Insufficient permissions' }; } - const { error } = await this.supabase + let revokeQuery = this.supabase .from('authorized_groups') .update({ status: 'revoked', @@ -262,6 +305,12 @@ export class AuthorizationService { .eq('platform', platform) .eq('platform_group_id', platformGroupId); + if (workspaceId) { + revokeQuery = revokeQuery.eq('workspace_id', workspaceId); + } + + const { error } = await revokeQuery; + if (error) { logger.error('Failed to revoke group', { error }); return { success: false, error: 'Failed to revoke group' }; @@ -280,10 +329,11 @@ export class AuthorizationService { platformUserId: string, trustLevel: TrustLevel, addedByPlatformUserId: string, - userId?: string + userId?: string, + workspaceId?: string ): Promise<{ success: boolean; error?: string }> { // Verify adder has permission - const adder = await this.isUserTrusted(platform, addedByPlatformUserId); + const adder = await this.isUserTrusted(platform, addedByPlatformUserId, workspaceId); if (!adder) { return { success: false, error: 'You are not a trusted user' }; } @@ -300,7 +350,7 @@ export class AuthorizationService { } // Check if already trusted - const existing = await this.isUserTrusted(platform, platformUserId); + const existing = await this.isUserTrusted(platform, platformUserId, workspaceId); if (existing) { return { success: false, error: 'User is already trusted' }; } @@ -313,6 +363,7 @@ export class AuthorizationService { platform_user_id: platformUserId, trust_level: trustLevel, added_by: adder.userId, + workspace_id: workspaceId || null, }); if (error) { @@ -327,7 +378,7 @@ export class AuthorizationService { /** * List all authorized groups for a platform */ - async listAuthorizedGroups(platform?: Platform): Promise { + async listAuthorizedGroups(platform?: Platform, workspaceId?: string): Promise { let query = this.supabase .from('authorized_groups') .select('*') @@ -336,6 +387,9 @@ export class AuthorizationService { if (platform) { query = query.eq('platform', platform); } + if (workspaceId) { + query = query.eq('workspace_id', workspaceId); + } const { data, error } = await query; @@ -345,6 +399,7 @@ export class AuthorizationService { return data.map((g) => ({ id: g.id, + workspaceId: g.workspace_id, platform: g.platform, platformGroupId: g.platform_group_id, groupName: g.group_name, @@ -358,7 +413,7 @@ export class AuthorizationService { /** * List all trusted users for a platform */ - async listTrustedUsers(platform?: Platform): Promise { + async listTrustedUsers(platform?: Platform, workspaceId?: string): Promise { let query = this.supabase .from('trusted_users') .select('*'); @@ -366,6 +421,9 @@ export class AuthorizationService { if (platform) { query = query.eq('platform', platform); } + if (workspaceId) { + query = query.eq('workspace_id', workspaceId); + } const { data, error } = await query; @@ -376,6 +434,7 @@ export class AuthorizationService { return data.map((u) => ({ id: u.id, userId: u.user_id, + workspaceId: u.workspace_id, platform: u.platform, platformUserId: u.platform_user_id, trustLevel: u.trust_level, diff --git a/packages/api/src/services/oauth.ts b/packages/api/src/services/oauth.ts index ce6e4b14..1cac323b 100644 --- a/packages/api/src/services/oauth.ts +++ b/packages/api/src/services/oauth.ts @@ -8,6 +8,7 @@ import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { env } from '../config/env'; import { logger } from '../utils/logger'; +import { getRequestContext, getSessionContext } from '../utils/request-context'; // OAuth provider configurations interface OAuthProviderConfig { @@ -41,6 +42,7 @@ const OAUTH_PROVIDERS: Record = { export interface ConnectedAccount { id: string; userId: string; + workspaceId: string | null; provider: string; providerAccountId: string; email: string | null; @@ -70,6 +72,11 @@ class OAuthService { this.supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY); } + private resolveWorkspaceId(workspaceId?: string | null): string | null | undefined { + if (workspaceId !== undefined) return workspaceId; + return getRequestContext()?.workspaceId ?? getSessionContext()?.workspaceId; + } + /** * Get the required scopes for a provider. * Used by the frontend to compare against user's current scopes. @@ -305,39 +312,67 @@ class OAuthService { userId: string, provider: string, tokens: TokenResponse, - userInfo: { id: string; email?: string; name?: string; picture?: string } + userInfo: { id: string; email?: string; name?: string; picture?: string }, + workspaceId?: string | null ): Promise { const expiresAt = tokens.expiresIn ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString() : null; const scopes = tokens.scope?.split(' ') || []; + const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId); - const { data, error } = await this.supabase + let existingQuery = this.supabase .from('connected_accounts') - .upsert( - { - user_id: userId, - provider, - provider_account_id: userInfo.id, - email: userInfo.email || null, - display_name: userInfo.name || null, - avatar_url: userInfo.picture || null, - access_token: tokens.accessToken, - refresh_token: tokens.refreshToken || null, - token_type: tokens.tokenType, - expires_at: expiresAt, - scopes, - status: 'active', - last_error: null, - updated_at: new Date().toISOString(), - }, - { - onConflict: 'user_id,provider,provider_account_id', - } - ) - .select() - .single(); + .select('id') + .eq('user_id', userId) + .eq('provider', provider) + .eq('provider_account_id', userInfo.id); + + if (resolvedWorkspaceId === null) { + existingQuery = existingQuery.is('workspace_id', null); + } else if (resolvedWorkspaceId) { + existingQuery = existingQuery.eq('workspace_id', resolvedWorkspaceId); + } + + const { data: existing, error: existingError } = await existingQuery + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (existingError) { + logger.error('Failed to look up connected account before save:', existingError); + throw new Error('Failed to save connected account'); + } + + const payload = { + user_id: userId, + provider, + provider_account_id: userInfo.id, + email: userInfo.email || null, + display_name: userInfo.name || null, + avatar_url: userInfo.picture || null, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken || null, + token_type: tokens.tokenType, + expires_at: expiresAt, + scopes, + status: 'active', + last_error: null, + updated_at: new Date().toISOString(), + ...(resolvedWorkspaceId !== undefined ? { workspace_id: resolvedWorkspaceId } : {}), + }; + + const saveQuery = existing + ? this.supabase + .from('connected_accounts') + .update(payload) + .eq('id', existing.id) + : this.supabase + .from('connected_accounts') + .insert(payload); + + const { data, error } = await saveQuery.select().single(); if (error) { logger.error('Failed to save connected account:', error); @@ -350,13 +385,22 @@ class OAuthService { /** * Get all connected accounts for a user */ - async getConnectedAccounts(userId: string): Promise { - const { data, error } = await this.supabase + async getConnectedAccounts(userId: string, workspaceId?: string | null): Promise { + const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId); + let query = this.supabase .from('connected_accounts') .select('*') .eq('user_id', userId) .order('created_at', { ascending: false }); + if (resolvedWorkspaceId === null) { + query = query.is('workspace_id', null); + } else if (resolvedWorkspaceId) { + query = query.eq('workspace_id', resolvedWorkspaceId); + } + + const { data, error } = await query; + if (error) { logger.error('Failed to get connected accounts:', error); throw new Error('Failed to get connected accounts'); @@ -370,17 +414,29 @@ class OAuthService { */ async getConnectedAccount( userId: string, - provider: string + provider: string, + workspaceId?: string | null ): Promise { - const { data, error } = await this.supabase + const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId); + let query = this.supabase .from('connected_accounts') .select('*') .eq('user_id', userId) .eq('provider', provider) - .eq('status', 'active') - .single(); + .eq('status', 'active'); + + if (resolvedWorkspaceId === null) { + query = query.is('workspace_id', null); + } else if (resolvedWorkspaceId) { + query = query.eq('workspace_id', resolvedWorkspaceId); + } + + const { data, error } = await query + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle(); - if (error && error.code !== 'PGRST116') { + if (error) { logger.error('Failed to get connected account:', error); throw new Error('Failed to get connected account'); } @@ -391,14 +447,25 @@ class OAuthService { /** * Get a valid access token, refreshing if necessary */ - async getValidAccessToken(userId: string, provider: string): Promise { - const { data: account, error } = await this.supabase + async getValidAccessToken(userId: string, provider: string, workspaceId?: string | null): Promise { + const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId); + let query = this.supabase .from('connected_accounts') .select('*') .eq('user_id', userId) .eq('provider', provider) - .eq('status', 'active') - .single(); + .eq('status', 'active'); + + if (resolvedWorkspaceId === null) { + query = query.is('workspace_id', null); + } else if (resolvedWorkspaceId) { + query = query.eq('workspace_id', resolvedWorkspaceId); + } + + const { data: account, error } = await query + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle(); if (error || !account) { throw new Error(`No active ${provider} account found`); @@ -459,14 +526,23 @@ class OAuthService { /** * Disconnect (revoke) a connected account */ - async disconnectAccount(accountId: string, userId: string): Promise { + async disconnectAccount(accountId: string, userId: string, workspaceId?: string | null): Promise { + const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId); + // First get the account to revoke the token - const { data: account } = await this.supabase + let accountQuery = this.supabase .from('connected_accounts') .select('*') .eq('id', accountId) - .eq('user_id', userId) - .single(); + .eq('user_id', userId); + + if (resolvedWorkspaceId === null) { + accountQuery = accountQuery.is('workspace_id', null); + } else if (resolvedWorkspaceId) { + accountQuery = accountQuery.eq('workspace_id', resolvedWorkspaceId); + } + + const { data: account } = await accountQuery.single(); if (!account) { throw new Error('Account not found'); @@ -486,12 +562,20 @@ class OAuthService { } // Delete the account record - const { error } = await this.supabase + let deleteQuery = this.supabase .from('connected_accounts') .delete() .eq('id', accountId) .eq('user_id', userId); + if (resolvedWorkspaceId === null) { + deleteQuery = deleteQuery.is('workspace_id', null); + } else if (resolvedWorkspaceId) { + deleteQuery = deleteQuery.eq('workspace_id', resolvedWorkspaceId); + } + + const { error } = await deleteQuery; + if (error) { throw new Error('Failed to disconnect account'); } @@ -516,6 +600,7 @@ class OAuthService { return { id: row.id as string, userId: row.user_id as string, + workspaceId: (row.workspace_id as string) ?? null, provider: row.provider as string, providerAccountId: row.provider_account_id as string, email: row.email as string | null, diff --git a/packages/api/src/utils/request-context.ts b/packages/api/src/utils/request-context.ts index 07be2846..e262ec2f 100644 --- a/packages/api/src/utils/request-context.ts +++ b/packages/api/src/utils/request-context.ts @@ -33,6 +33,8 @@ export interface RequestContextData { agentId?: string; /** Session ID if in a session */ sessionId?: string; + /** Active product workspace container ID */ + workspaceId?: string; /** Conversation ID for channel routing */ conversationId?: string; /** Request timestamp */ @@ -143,18 +145,22 @@ export function hasUserContext(): boolean { */ export function mergeWithContext>( args: T -): T & { userId?: string; email?: string; platform?: string; platformId?: string } { +): T & { userId?: string; email?: string; platform?: string; platformId?: string; workspaceId?: string } { const ctx = getUserFromContext(); - if (!ctx) return args as T & { userId?: string; email?: string; platform?: string; platformId?: string }; + const reqCtx = getRequestContext(); + if (!ctx && !reqCtx?.workspaceId) { + return args as T & { userId?: string; email?: string; platform?: string; platformId?: string; workspaceId?: string }; + } // Only fill in missing values from context const merged = { ...args, - userId: (args.userId as string | undefined) ?? ctx.userId, - email: (args.email as string | undefined) ?? ctx.email, - platform: (args.platform as string | undefined) ?? ctx.platform, - platformId: (args.platformId as string | undefined) ?? ctx.platformId, + userId: (args.userId as string | undefined) ?? ctx?.userId, + email: (args.email as string | undefined) ?? ctx?.email, + platform: (args.platform as string | undefined) ?? ctx?.platform, + platformId: (args.platformId as string | undefined) ?? ctx?.platformId, + workspaceId: (args.workspaceId as string | undefined) ?? reqCtx?.workspaceId, }; - return merged as T & { userId?: string; email?: string; platform?: string; platformId?: string }; + return merged as T & { userId?: string; email?: string; platform?: string; platformId?: string; workspaceId?: string }; } diff --git a/supabase/migrations/019_scope_admin_data_to_workspace_containers.sql b/supabase/migrations/019_scope_admin_data_to_workspace_containers.sql new file mode 100644 index 00000000..ab67e054 --- /dev/null +++ b/supabase/migrations/019_scope_admin_data_to_workspace_containers.sql @@ -0,0 +1,260 @@ +-- Scope admin/dashboard data to product workspace containers. +-- +-- This migration adds workspace_id to key admin-surface tables and backfills +-- existing rows to the user's default personal workspace. +-- +-- Backward-compatibility strategy: +-- - Columns are nullable during rollout. +-- - Backfill targets existing rows where we can infer ownership. +-- - Older code paths can continue writing rows without workspace_id temporarily. + +-- ===================================================== +-- Add workspace_id columns +-- ===================================================== + +ALTER TABLE IF EXISTS trusted_users + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS authorized_groups + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS group_challenge_codes + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS connected_accounts + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS artifacts + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS artifact_comments + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS artifact_history + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS agent_identities + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS agent_identity_history + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS user_identity + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +ALTER TABLE IF EXISTS user_identity_history + ADD COLUMN IF NOT EXISTS workspace_id UUID; + +-- ===================================================== +-- Add FK constraints (idempotent) +-- ===================================================== + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'trusted_users_workspace_id_fkey' + ) THEN + ALTER TABLE trusted_users + ADD CONSTRAINT trusted_users_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'authorized_groups_workspace_id_fkey' + ) THEN + ALTER TABLE authorized_groups + ADD CONSTRAINT authorized_groups_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'group_challenge_codes_workspace_id_fkey' + ) THEN + ALTER TABLE group_challenge_codes + ADD CONSTRAINT group_challenge_codes_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'connected_accounts_workspace_id_fkey' + ) THEN + ALTER TABLE connected_accounts + ADD CONSTRAINT connected_accounts_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'artifacts_workspace_id_fkey' + ) THEN + ALTER TABLE artifacts + ADD CONSTRAINT artifacts_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'artifact_comments_workspace_id_fkey' + ) THEN + ALTER TABLE artifact_comments + ADD CONSTRAINT artifact_comments_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'artifact_history_workspace_id_fkey' + ) THEN + ALTER TABLE artifact_history + ADD CONSTRAINT artifact_history_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'agent_identities_workspace_id_fkey' + ) THEN + ALTER TABLE agent_identities + ADD CONSTRAINT agent_identities_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'agent_identity_history_workspace_id_fkey' + ) THEN + ALTER TABLE agent_identity_history + ADD CONSTRAINT agent_identity_history_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'user_identity_workspace_id_fkey' + ) THEN + ALTER TABLE user_identity + ADD CONSTRAINT user_identity_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'user_identity_history_workspace_id_fkey' + ) THEN + ALTER TABLE user_identity_history + ADD CONSTRAINT user_identity_history_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace_containers(id) ON DELETE CASCADE; + END IF; +END $$; + +-- ===================================================== +-- Backfill existing rows to each user's personal workspace +-- ===================================================== + +UPDATE trusted_users tu +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE tu.workspace_id IS NULL + AND COALESCE(tu.user_id, tu.added_by) IS NOT NULL + AND wc.user_id = COALESCE(tu.user_id, tu.added_by) + AND wc.slug = 'personal'; + +UPDATE authorized_groups ag +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE ag.workspace_id IS NULL + AND ag.authorized_by IS NOT NULL + AND wc.user_id = ag.authorized_by + AND wc.slug = 'personal'; + +UPDATE group_challenge_codes gcc +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE gcc.workspace_id IS NULL + AND gcc.created_by IS NOT NULL + AND wc.user_id = gcc.created_by + AND wc.slug = 'personal'; + +UPDATE connected_accounts ca +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE ca.workspace_id IS NULL + AND wc.user_id = ca.user_id + AND wc.slug = 'personal'; + +UPDATE artifacts a +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE a.workspace_id IS NULL + AND wc.user_id = a.user_id + AND wc.slug = 'personal'; + +UPDATE artifact_comments ac +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE ac.workspace_id IS NULL + AND wc.user_id = ac.user_id + AND wc.slug = 'personal'; + +UPDATE artifact_history ah +SET workspace_id = a.workspace_id +FROM artifacts a +WHERE ah.workspace_id IS NULL + AND ah.artifact_id = a.id + AND a.workspace_id IS NOT NULL; + +UPDATE agent_identities ai +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE ai.workspace_id IS NULL + AND wc.user_id = ai.user_id + AND wc.slug = 'personal'; + +UPDATE agent_identity_history aih +SET workspace_id = ai.workspace_id +FROM agent_identities ai +WHERE aih.workspace_id IS NULL + AND aih.identity_id = ai.id + AND ai.workspace_id IS NOT NULL; + +UPDATE user_identity ui +SET workspace_id = wc.id +FROM workspace_containers wc +WHERE ui.workspace_id IS NULL + AND wc.user_id = ui.user_id + AND wc.slug = 'personal'; + +UPDATE user_identity_history uih +SET workspace_id = ui.workspace_id +FROM user_identity ui +WHERE uih.workspace_id IS NULL + AND uih.identity_id = ui.id + AND ui.workspace_id IS NOT NULL; + +-- ===================================================== +-- Indexes +-- ===================================================== + +CREATE INDEX IF NOT EXISTS idx_trusted_users_workspace_id ON trusted_users(workspace_id); +CREATE INDEX IF NOT EXISTS idx_authorized_groups_workspace_id ON authorized_groups(workspace_id); +CREATE INDEX IF NOT EXISTS idx_group_challenge_codes_workspace_id ON group_challenge_codes(workspace_id); +CREATE INDEX IF NOT EXISTS idx_connected_accounts_workspace_id ON connected_accounts(workspace_id); +CREATE INDEX IF NOT EXISTS idx_artifacts_workspace_id ON artifacts(workspace_id); +CREATE INDEX IF NOT EXISTS idx_artifact_comments_workspace_id ON artifact_comments(workspace_id); +CREATE INDEX IF NOT EXISTS idx_artifact_history_workspace_id ON artifact_history(workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_identities_workspace_id ON agent_identities(workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_identity_history_workspace_id ON agent_identity_history(workspace_id); +CREATE INDEX IF NOT EXISTS idx_user_identity_workspace_id ON user_identity(workspace_id); +CREATE INDEX IF NOT EXISTS idx_user_identity_history_workspace_id ON user_identity_history(workspace_id); From 7f4643675ab34e1f21710259650a0b522bd2c6fc Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 22:56:12 -0800 Subject: [PATCH 05/10] feat(cli): add workspace container commands --- packages/cli/src/cli.ts | 2 + .../cli/src/commands/workspace-container.ts | 235 ++++++++++++++++++ packages/cli/src/commands/workspace.ts | 1 - packages/cli/src/index.ts | 1 + 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/workspace-container.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 35cf0cd1..2c6645b7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -19,6 +19,7 @@ import { program } from 'commander'; import chalk from 'chalk'; import { registerWorkspaceCommands } from './commands/workspace.js'; +import { registerWorkspaceContainerCommands } from './commands/workspace-container.js'; import { registerAgentCommands } from './commands/agent.js'; import { registerSessionCommands } from './commands/session.js'; import { registerConfigCommands } from './commands/mcp.js'; @@ -161,6 +162,7 @@ program // Register subcommand groups registerWorkspaceCommands(program); +registerWorkspaceContainerCommands(program); registerAgentCommands(program); registerSessionCommands(program); registerConfigCommands(program); diff --git a/packages/cli/src/commands/workspace-container.ts b/packages/cli/src/commands/workspace-container.ts new file mode 100644 index 00000000..937b0768 --- /dev/null +++ b/packages/cli/src/commands/workspace-container.ts @@ -0,0 +1,235 @@ +/** + * Workspace Container Commands + * + * Manage product-level workspaces (personal/team scope). + * These are distinct from local git worktree studios (`sb studio ...`). + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +interface PcpConfig { + userId?: string; + email?: string; + agentMapping?: Record; + workspaceId?: string; +} + +interface WorkspaceContainer { + id: string; + name: string; + slug: string; + type: 'personal' | 'team'; + description?: string | null; + archivedAt?: string | null; +} + +function getConfigPath(): string { + return join(homedir(), '.pcp', 'config.json'); +} + +function getPcpConfig(): PcpConfig | null { + const configPath = getConfigPath(); + if (!existsSync(configPath)) return null; + + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return null; + } +} + +function savePcpConfig(config: PcpConfig): void { + const configPath = getConfigPath(); + const configDir = join(homedir(), '.pcp'); + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); +} + +function getPcpServerUrl(): string { + return process.env.PCP_SERVER_URL || 'http://localhost:3001'; +} + +async function fetchPcp(path: string, options?: RequestInit): Promise { + const url = `${getPcpServerUrl()}${path}`; + return fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); +} + +function unwrapToolResult(payload: unknown): Record { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid response payload'); + } + + const direct = payload as Record; + if (Array.isArray(direct.workspaces)) { + return direct; + } + + const mcpText = (direct.result as { content?: Array<{ text?: string }> } | undefined) + ?.content?.[0]?.text + || (direct.content as Array<{ text?: string }> | undefined)?.[0]?.text; + + if (typeof mcpText === 'string') { + try { + const parsed = JSON.parse(mcpText) as Record; + return parsed; + } catch { + // fall through + } + } + + return direct; +} + +async function listWorkspaceContainers(options: { all?: boolean; type?: 'personal' | 'team'; json?: boolean }): Promise { + const config = getPcpConfig(); + if (!config?.email) { + console.error(chalk.red('PCP not configured. Run: sb init')); + process.exit(1); + } + + const response = await fetchPcp('/api/mcp/call', { + method: 'POST', + body: JSON.stringify({ + tool: 'list_workspace_containers', + args: { + email: config.email, + includeArchived: options.all === true, + type: options.type, + ensurePersonal: true, + }, + }), + }); + + if (!response.ok) { + console.error(chalk.red(`Failed to list workspaces: ${await response.text()}`)); + process.exit(1); + } + + const raw = await response.json() as unknown; + const parsed = unwrapToolResult(raw); + const workspaces = Array.isArray(parsed.workspaces) + ? (parsed.workspaces as WorkspaceContainer[]) + : []; + + if (options.json) { + console.log(JSON.stringify({ selectedWorkspaceId: config.workspaceId, workspaces }, null, 2)); + return; + } + + if (workspaces.length === 0) { + console.log(chalk.yellow('No workspace containers found.')); + return; + } + + console.log(chalk.bold('\nWorkspace Containers:\n')); + + for (const workspace of workspaces) { + const selected = config.workspaceId === workspace.id; + const marker = selected ? chalk.green('●') : chalk.dim('○'); + const type = workspace.type === 'team' ? chalk.blue('team') : chalk.gray('personal'); + + console.log(` ${marker} ${chalk.cyan(workspace.name)} ${chalk.dim(`(${workspace.slug})`)} ${type}`); + console.log(chalk.dim(` id: ${workspace.id}`)); + if (workspace.description) { + console.log(chalk.dim(` ${workspace.description}`)); + } + if (workspace.archivedAt) { + console.log(chalk.yellow(` archived: ${workspace.archivedAt}`)); + } + console.log(''); + } +} + +async function useWorkspaceContainer(workspaceRef: string): Promise { + const config = getPcpConfig(); + if (!config?.email) { + console.error(chalk.red('PCP not configured. Run: sb init')); + process.exit(1); + } + + const response = await fetchPcp('/api/mcp/call', { + method: 'POST', + body: JSON.stringify({ + tool: 'list_workspace_containers', + args: { + email: config.email, + includeArchived: false, + ensurePersonal: true, + }, + }), + }); + + if (!response.ok) { + console.error(chalk.red(`Failed to list workspaces: ${await response.text()}`)); + process.exit(1); + } + + const raw = await response.json() as unknown; + const parsed = unwrapToolResult(raw); + const workspaces = Array.isArray(parsed.workspaces) + ? (parsed.workspaces as WorkspaceContainer[]) + : []; + const match = workspaces.find((w) => w.id === workspaceRef || w.slug === workspaceRef); + + if (!match) { + console.error(chalk.red(`Workspace not found: ${workspaceRef}`)); + process.exit(1); + } + + savePcpConfig({ + ...config, + workspaceId: match.id, + }); + + console.log(chalk.green(`Selected workspace: ${match.name} (${match.slug})`)); + console.log(chalk.dim(` id: ${match.id}`)); +} + +function currentWorkspaceContainer(): void { + const config = getPcpConfig(); + if (!config) { + console.error(chalk.red('PCP not configured. Run: sb init')); + process.exit(1); + } + + if (!config.workspaceId) { + console.log(chalk.yellow('No workspace selected.')); + console.log(chalk.dim('Use `sb workspace use ` to select one.')); + return; + } + + console.log(config.workspaceId); +} + +export function registerWorkspaceContainerCommands(program: Command): void { + const workspace = program + .command('workspace') + .description('Product workspace container management (personal/team scope)'); + + workspace.command('list') + .alias('ls') + .option('--all', 'Include archived workspaces') + .option('--type ', 'Filter by workspace type (personal|team)') + .option('--json', 'Output JSON') + .action(listWorkspaceContainers); + + workspace.command('use ') + .description('Select the active workspace container for this machine') + .action(useWorkspaceContainer); + + workspace.command('current') + .description('Print selected workspace container ID') + .action(currentWorkspaceContainer); +} diff --git a/packages/cli/src/commands/workspace.ts b/packages/cli/src/commands/workspace.ts index 8f97a5d0..c4e63671 100644 --- a/packages/cli/src/commands/workspace.ts +++ b/packages/cli/src/commands/workspace.ts @@ -630,7 +630,6 @@ export function registerWorkspaceCommands(program: Command): void { const ws = program .command('studio') .alias('ws') - .alias('workspace') .description('Studio management for parallel development (worktree-backed)'); ws.command('init [parent-name]') diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cd20919f..ebecf421 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -26,6 +26,7 @@ export interface PcpConfig { userId?: string; email?: string; agentMapping?: Record; + workspaceId?: string; } export interface CreateWorkspaceOptions { From d9f62dd0d0028e347e9a2923389b1676500e1067 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 22:56:19 -0800 Subject: [PATCH 06/10] feat(web): add workspace selector and scoped API header --- .../web/src/components/layout/sidebar.tsx | 74 +++++++++++++++++++ packages/web/src/lib/api/client.ts | 6 ++ packages/web/src/lib/workspace-selection.ts | 17 +++++ 3 files changed, 97 insertions(+) create mode 100644 packages/web/src/lib/workspace-selection.ts diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index b4f4d3e7..33d98fd2 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -18,6 +18,9 @@ import { import { cn } from '@/lib/utils'; import { createClient } from '@/lib/supabase/client'; import { useRouter } from 'next/navigation'; +import { useApiQuery, useQueryClient } from '@/lib/api/hooks'; +import { getSelectedWorkspaceId, setSelectedWorkspaceId } from '@/lib/workspace-selection'; +import { useEffect, useMemo, useState } from 'react'; const navigation = [ { name: 'Dashboard', href: '/', icon: Home }, @@ -32,13 +35,64 @@ const navigation = [ { name: 'Skills', href: '/skills', icon: Puzzle }, ]; +interface WorkspaceOption { + id: string; + name: string; + slug: string; + type: 'personal' | 'team'; +} + +interface WorkspaceListResponse { + currentWorkspaceId: string; + workspaces: WorkspaceOption[]; +} + export function Sidebar() { const pathname = usePathname(); const router = useRouter(); + const queryClient = useQueryClient(); + const [selectedWorkspaceId, setSelectedWorkspaceState] = useState(null); + + const { data: workspaceData, isLoading: workspacesLoading } = useApiQuery( + ['workspace-containers'], + '/api/admin/workspaces', + { + retry: 1, + }, + ); + + const workspaces = workspaceData?.workspaces || []; + + useEffect(() => { + const locallySelected = getSelectedWorkspaceId(); + if (locallySelected) { + setSelectedWorkspaceState(locallySelected); + return; + } + + if (workspaceData?.currentWorkspaceId) { + setSelectedWorkspaceId(workspaceData.currentWorkspaceId); + setSelectedWorkspaceState(workspaceData.currentWorkspaceId); + } + }, [workspaceData?.currentWorkspaceId]); + + const resolvedWorkspaceId = useMemo(() => { + if (selectedWorkspaceId) return selectedWorkspaceId; + return workspaceData?.currentWorkspaceId ?? ''; + }, [selectedWorkspaceId, workspaceData?.currentWorkspaceId]); + + const handleWorkspaceChange = (workspaceId: string) => { + setSelectedWorkspaceId(workspaceId); + setSelectedWorkspaceState(workspaceId); + // Force all data queries to refetch with the new workspace header. + queryClient.invalidateQueries(); + router.refresh(); + }; const handleSignOut = async () => { const supabase = createClient(); await supabase.auth.signOut(); + setSelectedWorkspaceId(null); router.push('/login'); }; @@ -47,6 +101,26 @@ export function Sidebar() {
PCP Admin
+
+ + +