diff --git a/apps/gateway/src/__tests__/resource-ownership.test.ts b/apps/gateway/src/__tests__/resource-ownership.test.ts index b1718e3..a1b7dfb 100644 --- a/apps/gateway/src/__tests__/resource-ownership.test.ts +++ b/apps/gateway/src/__tests__/resource-ownership.test.ts @@ -1,4 +1,4 @@ -import { ForbiddenException } from '@nestjs/common'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { ConversationsController } from '../conversations/conversations.controller.js'; import { MissionsController } from '../missions/missions.controller.js'; @@ -25,12 +25,21 @@ function createBrain() { }, missions: { findAll: vi.fn(), + findAllByUser: vi.fn(), findById: vi.fn(), + findByIdAndUser: vi.fn(), findByProject: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn(), }, + missionTasks: { + findByMissionAndUser: vi.fn(), + findByIdAndUser: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, tasks: { findAll: vi.fn(), findById: vi.fn(), @@ -65,14 +74,14 @@ describe('Resource ownership checks', () => { ); }); - it('forbids access to a mission owned by another project owner', async () => { + it('forbids access to a mission owned by another user', async () => { const brain = createBrain(); - brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' }); - brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' }); + // findByIdAndUser returns undefined when the mission doesn't belong to the user + brain.missions.findByIdAndUser.mockResolvedValue(undefined); const controller = new MissionsController(brain as never); await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf( - ForbiddenException, + NotFoundException, ); }); diff --git a/apps/gateway/src/agent/agent-config.dto.ts b/apps/gateway/src/agent/agent-config.dto.ts new file mode 100644 index 0000000..3fbbea8 --- /dev/null +++ b/apps/gateway/src/agent/agent-config.dto.ts @@ -0,0 +1,97 @@ +import { + IsArray, + IsBoolean, + IsIn, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +const agentStatuses = ['idle', 'active', 'error', 'offline'] as const; + +export class CreateAgentConfigDto { + @IsString() + @MaxLength(255) + name!: string; + + @IsString() + @MaxLength(255) + provider!: string; + + @IsString() + @MaxLength(255) + model!: string; + + @IsOptional() + @IsIn(agentStatuses) + status?: 'idle' | 'active' | 'error' | 'offline'; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(50_000) + systemPrompt?: string; + + @IsOptional() + @IsArray() + allowedTools?: string[]; + + @IsOptional() + @IsArray() + skills?: string[]; + + @IsOptional() + @IsBoolean() + isSystem?: boolean; + + @IsOptional() + @IsObject() + config?: Record; +} + +export class UpdateAgentConfigDto { + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + provider?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + model?: string; + + @IsOptional() + @IsIn(agentStatuses) + status?: 'idle' | 'active' | 'error' | 'offline'; + + @IsOptional() + @IsUUID() + projectId?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50_000) + systemPrompt?: string | null; + + @IsOptional() + @IsArray() + allowedTools?: string[] | null; + + @IsOptional() + @IsArray() + skills?: string[] | null; + + @IsOptional() + @IsObject() + config?: Record | null; +} diff --git a/apps/gateway/src/agent/agent-configs.controller.ts b/apps/gateway/src/agent/agent-configs.controller.ts new file mode 100644 index 0000000..79a233a --- /dev/null +++ b/apps/gateway/src/agent/agent-configs.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import type { Brain } from '@mosaic/brain'; +import { BRAIN } from '../brain/brain.tokens.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { CreateAgentConfigDto, UpdateAgentConfigDto } from './agent-config.dto.js'; + +@Controller('api/agents') +@UseGuards(AuthGuard) +export class AgentConfigsController { + constructor(@Inject(BRAIN) private readonly brain: Brain) {} + + @Get() + async list(@CurrentUser() user: { id: string; role?: string }) { + return this.brain.agents.findAccessible(user.id); + } + + @Get(':id') + async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) { + const agent = await this.brain.agents.findById(id); + if (!agent) throw new NotFoundException('Agent not found'); + if (!agent.isSystem && agent.ownerId !== user.id) { + throw new ForbiddenException('Agent does not belong to the current user'); + } + return agent; + } + + @Post() + async create(@Body() dto: CreateAgentConfigDto, @CurrentUser() user: { id: string }) { + return this.brain.agents.create({ + ...dto, + ownerId: user.id, + isSystem: false, + }); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() dto: UpdateAgentConfigDto, + @CurrentUser() user: { id: string; role?: string }, + ) { + const agent = await this.brain.agents.findById(id); + if (!agent) throw new NotFoundException('Agent not found'); + if (agent.isSystem && user.role !== 'admin') { + throw new ForbiddenException('Only admins can update system agents'); + } + if (!agent.isSystem && agent.ownerId !== user.id) { + throw new ForbiddenException('Agent does not belong to the current user'); + } + const updated = await this.brain.agents.update(id, dto); + if (!updated) throw new NotFoundException('Agent not found'); + return updated; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id') id: string, @CurrentUser() user: { id: string; role?: string }) { + const agent = await this.brain.agents.findById(id); + if (!agent) throw new NotFoundException('Agent not found'); + if (agent.isSystem) { + throw new ForbiddenException('Cannot delete system agents'); + } + if (agent.ownerId !== user.id) { + throw new ForbiddenException('Agent does not belong to the current user'); + } + const deleted = await this.brain.agents.remove(id); + if (!deleted) throw new NotFoundException('Agent not found'); + } +} diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 1fc8d73..d32f224 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -5,6 +5,7 @@ import { RoutingService } from './routing.service.js'; import { SkillLoaderService } from './skill-loader.service.js'; import { ProvidersController } from './providers.controller.js'; import { SessionsController } from './sessions.controller.js'; +import { AgentConfigsController } from './agent-configs.controller.js'; import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; @@ -13,7 +14,7 @@ import { SkillsModule } from '../skills/skills.module.js'; @Module({ imports: [CoordModule, McpClientModule, SkillsModule], providers: [ProviderService, RoutingService, SkillLoaderService, AgentService], - controllers: [ProvidersController, SessionsController], + controllers: [ProvidersController, SessionsController, AgentConfigsController], exports: [AgentService, ProviderService, RoutingService, SkillLoaderService], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 3085267..73b60e8 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -49,6 +49,12 @@ export interface AgentSessionOptions { allowedTools?: string[]; /** Whether the requesting user has admin privileges. Controls default tool access. */ isAdmin?: boolean; + /** + * DB agent config ID. When provided, loads agent config from DB and merges + * provider, model, systemPrompt, and allowedTools. Explicit call-site options + * take precedence over config values. + */ + agentConfigId?: string; } export interface AgentSession { @@ -146,16 +152,39 @@ export class AgentService implements OnModuleDestroy { sessionId: string, options?: AgentSessionOptions, ): Promise { - const model = this.resolveModel(options); + // Merge DB agent config when agentConfigId is provided + let mergedOptions = options; + if (options?.agentConfigId) { + const agentConfig = await this.brain.agents.findById(options.agentConfigId); + if (agentConfig) { + mergedOptions = { + provider: options.provider ?? agentConfig.provider, + modelId: options.modelId ?? agentConfig.model, + systemPrompt: options.systemPrompt ?? agentConfig.systemPrompt ?? undefined, + allowedTools: options.allowedTools ?? agentConfig.allowedTools ?? undefined, + sandboxDir: options.sandboxDir, + isAdmin: options.isAdmin, + agentConfigId: options.agentConfigId, + }; + this.logger.log( + `Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`, + ); + } + } + + const model = this.resolveModel(mergedOptions); const providerName = model?.provider ?? 'default'; const modelId = model?.id ?? 'default'; // Resolve sandbox directory: option > env var > process.cwd() const sandboxDir = - options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd(); + mergedOptions?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd(); // Resolve allowed tool set - const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools); + const allowedTools = this.resolveAllowedTools( + mergedOptions?.isAdmin ?? false, + mergedOptions?.allowedTools, + ); this.logger.log( `Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`, @@ -194,7 +223,8 @@ export class AgentService implements OnModuleDestroy { } // Build system prompt: platform prompt + skill additions appended - const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined; + const platformPrompt = + mergedOptions?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined; const appendSystemPrompt = promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined; diff --git a/apps/gateway/src/chat/chat.dto.ts b/apps/gateway/src/chat/chat.dto.ts index 92bb32d..8e90297 100644 --- a/apps/gateway/src/chat/chat.dto.ts +++ b/apps/gateway/src/chat/chat.dto.ts @@ -28,4 +28,8 @@ export class ChatSocketMessageDto { @IsString() @MaxLength(255) modelId?: string; + + @IsOptional() + @IsUUID() + agentId?: string; } diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 01969eb..0b1658e 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -83,6 +83,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa agentSession = await this.agentService.createSession(conversationId, { provider: data.provider, modelId: data.modelId, + agentConfigId: data.agentId, }); } } catch (err) { diff --git a/apps/gateway/src/coord/coord.controller.ts b/apps/gateway/src/coord/coord.controller.ts index 4d92eb2..f1db597 100644 --- a/apps/gateway/src/coord/coord.controller.ts +++ b/apps/gateway/src/coord/coord.controller.ts @@ -1,30 +1,17 @@ import { BadRequestException, - Body, Controller, - Delete, Get, - HttpCode, - HttpStatus, Inject, NotFoundException, Param, - Patch, - Post, Query, UseGuards, } from '@nestjs/common'; import fs from 'node:fs'; import path from 'node:path'; import { AuthGuard } from '../auth/auth.guard.js'; -import { CurrentUser } from '../auth/current-user.decorator.js'; import { CoordService } from './coord.service.js'; -import type { - CreateDbMissionDto, - UpdateDbMissionDto, - CreateMissionTaskDto, - UpdateMissionTaskDto, -} from './coord.dto.js'; /** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */ function findMonorepoRoot(start: string): string { @@ -57,13 +44,15 @@ function resolveAndValidatePath(raw: string | undefined): string { return resolved; } +/** + * File-based coord endpoints for agent tool consumption. + * DB-backed mission CRUD has moved to MissionsController at /api/missions. + */ @Controller('api/coord') @UseGuards(AuthGuard) export class CoordController { constructor(@Inject(CoordService) private readonly coordService: CoordService) {} - // ── File-based coord endpoints (legacy) ── - @Get('status') async missionStatus(@Query('projectPath') projectPath?: string) { const resolvedPath = resolveAndValidatePath(projectPath); @@ -85,121 +74,4 @@ export class CoordController { if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`); return detail; } - - // ── DB-backed mission endpoints ── - - @Get('missions') - async listDbMissions(@CurrentUser() user: { id: string }) { - return this.coordService.getMissionsByUser(user.id); - } - - @Get('missions/:id') - async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) { - const mission = await this.coordService.getMissionByIdAndUser(id, user.id); - if (!mission) throw new NotFoundException('Mission not found'); - return mission; - } - - @Post('missions') - async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) { - return this.coordService.createDbMission({ - name: dto.name, - description: dto.description, - projectId: dto.projectId, - userId: user.id, - phase: dto.phase, - milestones: dto.milestones, - config: dto.config, - status: dto.status, - }); - } - - @Patch('missions/:id') - async updateDbMission( - @Param('id') id: string, - @Body() dto: UpdateDbMissionDto, - @CurrentUser() user: { id: string }, - ) { - const mission = await this.coordService.updateDbMission(id, user.id, dto); - if (!mission) throw new NotFoundException('Mission not found'); - return mission; - } - - @Delete('missions/:id') - @HttpCode(HttpStatus.NO_CONTENT) - async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) { - const deleted = await this.coordService.deleteDbMission(id, user.id); - if (!deleted) throw new NotFoundException('Mission not found'); - } - - // ── DB-backed mission task endpoints ── - - @Get('missions/:missionId/mission-tasks') - async listMissionTasks( - @Param('missionId') missionId: string, - @CurrentUser() user: { id: string }, - ) { - const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); - if (!mission) throw new NotFoundException('Mission not found'); - return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id); - } - - @Get('missions/:missionId/mission-tasks/:taskId') - async getMissionTask( - @Param('missionId') missionId: string, - @Param('taskId') taskId: string, - @CurrentUser() user: { id: string }, - ) { - const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); - if (!mission) throw new NotFoundException('Mission not found'); - const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id); - if (!task) throw new NotFoundException('Mission task not found'); - return task; - } - - @Post('missions/:missionId/mission-tasks') - async createMissionTask( - @Param('missionId') missionId: string, - @Body() dto: CreateMissionTaskDto, - @CurrentUser() user: { id: string }, - ) { - const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); - if (!mission) throw new NotFoundException('Mission not found'); - return this.coordService.createMissionTask({ - missionId, - taskId: dto.taskId, - userId: user.id, - status: dto.status, - description: dto.description, - notes: dto.notes, - pr: dto.pr, - }); - } - - @Patch('missions/:missionId/mission-tasks/:taskId') - async updateMissionTask( - @Param('missionId') missionId: string, - @Param('taskId') taskId: string, - @Body() dto: UpdateMissionTaskDto, - @CurrentUser() user: { id: string }, - ) { - const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); - if (!mission) throw new NotFoundException('Mission not found'); - const updated = await this.coordService.updateMissionTask(taskId, user.id, dto); - if (!updated) throw new NotFoundException('Mission task not found'); - return updated; - } - - @Delete('missions/:missionId/mission-tasks/:taskId') - @HttpCode(HttpStatus.NO_CONTENT) - async deleteMissionTask( - @Param('missionId') missionId: string, - @Param('taskId') taskId: string, - @CurrentUser() user: { id: string }, - ) { - const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); - if (!mission) throw new NotFoundException('Mission not found'); - const deleted = await this.coordService.deleteMissionTask(taskId, user.id); - if (!deleted) throw new NotFoundException('Mission task not found'); - } } diff --git a/apps/gateway/src/coord/coord.service.ts b/apps/gateway/src/coord/coord.service.ts index e20111a..485e618 100644 --- a/apps/gateway/src/coord/coord.service.ts +++ b/apps/gateway/src/coord/coord.service.ts @@ -1,6 +1,4 @@ -import { Injectable, Logger, Inject } from '@nestjs/common'; -import type { Brain } from '@mosaic/brain'; -import { BRAIN } from '../brain/brain.tokens.js'; +import { Injectable, Logger } from '@nestjs/common'; import { loadMission, getMissionStatus, @@ -14,12 +12,14 @@ import { import { promises as fs } from 'node:fs'; import path from 'node:path'; +/** + * File-based coord operations for agent tool consumption. + * DB-backed mission CRUD is handled directly by MissionsController via Brain repos. + */ @Injectable() export class CoordService { private readonly logger = new Logger(CoordService.name); - constructor(@Inject(BRAIN) private readonly brain: Brain) {} - async loadMission(projectPath: string): Promise { try { return await loadMission(projectPath); @@ -74,68 +74,4 @@ export class CoordService { return []; } } - - // ── DB-backed methods for multi-tenant mission management ── - - async getMissionsByUser(userId: string) { - return this.brain.missions.findAllByUser(userId); - } - - async getMissionByIdAndUser(id: string, userId: string) { - return this.brain.missions.findByIdAndUser(id, userId); - } - - async getMissionsByProjectAndUser(projectId: string, userId: string) { - return this.brain.missions.findByProjectAndUser(projectId, userId); - } - - async createDbMission(data: Parameters[0]) { - return this.brain.missions.create(data); - } - - async updateDbMission( - id: string, - userId: string, - data: Parameters[1], - ) { - const existing = await this.brain.missions.findByIdAndUser(id, userId); - if (!existing) return null; - return this.brain.missions.update(id, data); - } - - async deleteDbMission(id: string, userId: string) { - const existing = await this.brain.missions.findByIdAndUser(id, userId); - if (!existing) return false; - return this.brain.missions.remove(id); - } - - // ── DB-backed methods for mission tasks (coord tracking) ── - - async getMissionTasksByMissionAndUser(missionId: string, userId: string) { - return this.brain.missionTasks.findByMissionAndUser(missionId, userId); - } - - async getMissionTaskByIdAndUser(id: string, userId: string) { - return this.brain.missionTasks.findByIdAndUser(id, userId); - } - - async createMissionTask(data: Parameters[0]) { - return this.brain.missionTasks.create(data); - } - - async updateMissionTask( - id: string, - userId: string, - data: Parameters[1], - ) { - const existing = await this.brain.missionTasks.findByIdAndUser(id, userId); - if (!existing) return null; - return this.brain.missionTasks.update(id, data); - } - - async deleteMissionTask(id: string, userId: string) { - const existing = await this.brain.missionTasks.findByIdAndUser(id, userId); - if (!existing) return false; - return this.brain.missionTasks.remove(id); - } } diff --git a/apps/gateway/src/missions/missions.controller.ts b/apps/gateway/src/missions/missions.controller.ts index 1671d45..7480fed 100644 --- a/apps/gateway/src/missions/missions.controller.ts +++ b/apps/gateway/src/missions/missions.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, Delete, - ForbiddenException, Get, HttpCode, HttpStatus, @@ -17,33 +16,42 @@ import type { Brain } from '@mosaic/brain'; import { BRAIN } from '../brain/brain.tokens.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; -import { assertOwner } from '../auth/resource-ownership.js'; -import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js'; +import { + CreateMissionDto, + UpdateMissionDto, + CreateMissionTaskDto, + UpdateMissionTaskDto, +} from './missions.dto.js'; @Controller('api/missions') @UseGuards(AuthGuard) export class MissionsController { constructor(@Inject(BRAIN) private readonly brain: Brain) {} + // ── Missions CRUD (user-scoped) ── + @Get() - async list() { - return this.brain.missions.findAll(); + async list(@CurrentUser() user: { id: string }) { + return this.brain.missions.findAllByUser(user.id); } @Get(':id') async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) { - return this.getOwnedMission(id, user.id); + const mission = await this.brain.missions.findByIdAndUser(id, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + return mission; } @Post() async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) { - if (dto.projectId) { - await this.getOwnedProject(dto.projectId, user.id, 'Mission'); - } return this.brain.missions.create({ name: dto.name, description: dto.description, projectId: dto.projectId, + userId: user.id, + phase: dto.phase, + milestones: dto.milestones, + config: dto.config, status: dto.status, }); } @@ -54,10 +62,8 @@ export class MissionsController { @Body() dto: UpdateMissionDto, @CurrentUser() user: { id: string }, ) { - await this.getOwnedMission(id, user.id); - if (dto.projectId) { - await this.getOwnedProject(dto.projectId, user.id, 'Mission'); - } + const existing = await this.brain.missions.findByIdAndUser(id, user.id); + if (!existing) throw new NotFoundException('Mission not found'); const mission = await this.brain.missions.update(id, dto); if (!mission) throw new NotFoundException('Mission not found'); return mission; @@ -66,33 +72,81 @@ export class MissionsController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { - await this.getOwnedMission(id, user.id); + const existing = await this.brain.missions.findByIdAndUser(id, user.id); + if (!existing) throw new NotFoundException('Mission not found'); const deleted = await this.brain.missions.remove(id); if (!deleted) throw new NotFoundException('Mission not found'); } - private async getOwnedMission(id: string, userId: string) { - const mission = await this.brain.missions.findById(id); + // ── Mission Tasks sub-routes ── + + @Get(':missionId/tasks') + async listTasks(@Param('missionId') missionId: string, @CurrentUser() user: { id: string }) { + const mission = await this.brain.missions.findByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); - await this.getOwnedProject(mission.projectId, userId, 'Mission'); - return mission; + return this.brain.missionTasks.findByMissionAndUser(missionId, user.id); } - private async getOwnedProject( - projectId: string | null | undefined, - userId: string, - resourceName: string, + @Get(':missionId/tasks/:taskId') + async getTask( + @Param('missionId') missionId: string, + @Param('taskId') taskId: string, + @CurrentUser() user: { id: string }, ) { - if (!projectId) { - throw new ForbiddenException(`${resourceName} does not belong to the current user`); - } + const mission = await this.brain.missions.findByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + const task = await this.brain.missionTasks.findByIdAndUser(taskId, user.id); + if (!task) throw new NotFoundException('Mission task not found'); + return task; + } - const project = await this.brain.projects.findById(projectId); - if (!project) { - throw new ForbiddenException(`${resourceName} does not belong to the current user`); - } + @Post(':missionId/tasks') + async createTask( + @Param('missionId') missionId: string, + @Body() dto: CreateMissionTaskDto, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.brain.missions.findByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + return this.brain.missionTasks.create({ + missionId, + taskId: dto.taskId, + userId: user.id, + status: dto.status, + description: dto.description, + notes: dto.notes, + pr: dto.pr, + }); + } - assertOwner(project.ownerId, userId, resourceName); - return project; + @Patch(':missionId/tasks/:taskId') + async updateTask( + @Param('missionId') missionId: string, + @Param('taskId') taskId: string, + @Body() dto: UpdateMissionTaskDto, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.brain.missions.findByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id); + if (!existing) throw new NotFoundException('Mission task not found'); + const updated = await this.brain.missionTasks.update(taskId, dto); + if (!updated) throw new NotFoundException('Mission task not found'); + return updated; + } + + @Delete(':missionId/tasks/:taskId') + @HttpCode(HttpStatus.NO_CONTENT) + async removeTask( + @Param('missionId') missionId: string, + @Param('taskId') taskId: string, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.brain.missions.findByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id); + if (!existing) throw new NotFoundException('Mission task not found'); + const deleted = await this.brain.missionTasks.remove(taskId); + if (!deleted) throw new NotFoundException('Mission task not found'); } } diff --git a/apps/gateway/src/missions/missions.dto.ts b/apps/gateway/src/missions/missions.dto.ts index a369886..d425e9b 100644 --- a/apps/gateway/src/missions/missions.dto.ts +++ b/apps/gateway/src/missions/missions.dto.ts @@ -1,6 +1,7 @@ -import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { IsArray, IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const; +const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const; export class CreateMissionDto { @IsString() @@ -19,6 +20,19 @@ export class CreateMissionDto { @IsOptional() @IsIn(missionStatuses) status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed'; + + @IsOptional() + @IsString() + @MaxLength(255) + phase?: string; + + @IsOptional() + @IsArray() + milestones?: Record[]; + + @IsOptional() + @IsObject() + config?: Record; } export class UpdateMissionDto { @@ -40,7 +54,70 @@ export class UpdateMissionDto { @IsIn(missionStatuses) status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed'; + @IsOptional() + @IsString() + @MaxLength(255) + phase?: string; + + @IsOptional() + @IsArray() + milestones?: Record[]; + + @IsOptional() + @IsObject() + config?: Record; + @IsOptional() @IsObject() metadata?: Record | null; } + +export class CreateMissionTaskDto { + @IsOptional() + @IsUUID() + taskId?: string; + + @IsOptional() + @IsIn(taskStatuses) + status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; + + @IsOptional() + @IsString() + @MaxLength(10_000) + description?: string; + + @IsOptional() + @IsString() + @MaxLength(10_000) + notes?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + pr?: string; +} + +export class UpdateMissionTaskDto { + @IsOptional() + @IsUUID() + taskId?: string; + + @IsOptional() + @IsIn(taskStatuses) + status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; + + @IsOptional() + @IsString() + @MaxLength(10_000) + description?: string; + + @IsOptional() + @IsString() + @MaxLength(10_000) + notes?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + pr?: string; +} diff --git a/docs/TASKS.md b/docs/TASKS.md index 9c49f4f..a977f74 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -2,80 +2,82 @@ > Single-writer: orchestrator only. Workers read but never modify. -| id | status | milestone | description | pr | notes | -| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- | -| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 | -| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 | -| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 | -| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 | -| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 | -| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 | -| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 | -| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 | -| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 | -| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 | -| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 | -| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 | -| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 | -| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 | -| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 | -| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 | -| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 | -| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 | -| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 | -| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 | -| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 | -| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 | -| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 | -| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 | -| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 | -| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 | -| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 | -| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 | -| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 | -| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 | -| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 | -| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 | -| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 | -| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 | -| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 | -| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 | -| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 | -| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 | -| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 | -| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 | -| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 | -| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 | -| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 | -| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 | -| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 | -| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 | -| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 | -| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 | -| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 | -| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 | -| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 | -| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done | -| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done | -| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done | -| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done | -| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done | -| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done | -| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done | -| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done | -| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done | -| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done | -| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done | -| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done | -| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done | -| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done | -| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done | -| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done | -| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done | -| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done | -| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done | -| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 | -| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 | -| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 | -| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 | -| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 | +| id | status | milestone | description | pr | notes | +| ------ | ----------- | --------- | --------------------------------------------------------------------- | ---- | ------------- | +| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 | +| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 | +| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 | +| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 | +| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 | +| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 | +| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 | +| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 | +| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 | +| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 | +| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 | +| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 | +| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 | +| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 | +| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 | +| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 | +| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 | +| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 | +| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 | +| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 | +| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 | +| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 | +| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 | +| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 | +| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 | +| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 | +| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 | +| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 | +| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 | +| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 | +| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 | +| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 | +| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 | +| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 | +| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 | +| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 | +| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 | +| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 | +| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 | +| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 | +| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 | +| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 | +| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 | +| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 | +| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 | +| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 | +| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 | +| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 | +| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 | +| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 | +| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 | +| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done | +| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done | +| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done | +| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done | +| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done | +| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done | +| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done | +| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done | +| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done | +| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done | +| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done | +| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done | +| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done | +| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done | +| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done | +| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done | +| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done | +| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done | +| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done | +| P8-005 | in-progress | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | — | | +| P8-006 | not-started | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | — | | +| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 | +| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 | +| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 | +| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 | +| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 | diff --git a/packages/brain/src/agents.ts b/packages/brain/src/agents.ts new file mode 100644 index 0000000..6a0a179 --- /dev/null +++ b/packages/brain/src/agents.ts @@ -0,0 +1,58 @@ +import { eq, or, type Db, agents } from '@mosaic/db'; + +export type Agent = typeof agents.$inferSelect; +export type NewAgent = typeof agents.$inferInsert; + +export function createAgentsRepo(db: Db) { + return { + async findAll(): Promise { + return db.select().from(agents); + }, + + async findById(id: string): Promise { + const rows = await db.select().from(agents).where(eq(agents.id, id)); + return rows[0]; + }, + + async findByName(name: string): Promise { + const rows = await db.select().from(agents).where(eq(agents.name, name)); + return rows[0]; + }, + + async findByProject(projectId: string): Promise { + return db.select().from(agents).where(eq(agents.projectId, projectId)); + }, + + async findSystem(): Promise { + return db.select().from(agents).where(eq(agents.isSystem, true)); + }, + + async findAccessible(ownerId: string): Promise { + return db + .select() + .from(agents) + .where(or(eq(agents.ownerId, ownerId), eq(agents.isSystem, true))); + }, + + async create(data: NewAgent): Promise { + const rows = await db.insert(agents).values(data).returning(); + return rows[0]!; + }, + + async update(id: string, data: Partial): Promise { + const rows = await db + .update(agents) + .set({ ...data, updatedAt: new Date() }) + .where(eq(agents.id, id)) + .returning(); + return rows[0]; + }, + + async remove(id: string): Promise { + const rows = await db.delete(agents).where(eq(agents.id, id)).returning(); + return rows.length > 0; + }, + }; +} + +export type AgentsRepo = ReturnType; diff --git a/packages/brain/src/brain.ts b/packages/brain/src/brain.ts index 9c3992a..1b93882 100644 --- a/packages/brain/src/brain.ts +++ b/packages/brain/src/brain.ts @@ -4,6 +4,7 @@ import { createMissionsRepo, type MissionsRepo } from './missions.js'; import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js'; import { createTasksRepo, type TasksRepo } from './tasks.js'; import { createConversationsRepo, type ConversationsRepo } from './conversations.js'; +import { createAgentsRepo, type AgentsRepo } from './agents.js'; export interface Brain { projects: ProjectsRepo; @@ -11,6 +12,7 @@ export interface Brain { missionTasks: MissionTasksRepo; tasks: TasksRepo; conversations: ConversationsRepo; + agents: AgentsRepo; } export function createBrain(db: Db): Brain { @@ -20,5 +22,6 @@ export function createBrain(db: Db): Brain { missionTasks: createMissionTasksRepo(db), tasks: createTasksRepo(db), conversations: createConversationsRepo(db), + agents: createAgentsRepo(db), }; } diff --git a/packages/brain/src/index.ts b/packages/brain/src/index.ts index 5921577..4cde737 100644 --- a/packages/brain/src/index.ts +++ b/packages/brain/src/index.ts @@ -26,3 +26,9 @@ export { type Message, type NewMessage, } from './conversations.js'; +export { + createAgentsRepo, + type AgentsRepo, + type Agent as AgentConfig, + type NewAgent as NewAgentConfig, +} from './agents.js'; diff --git a/packages/cli/package.json b/packages/cli/package.json index 680dd3c..2dd77fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,6 +21,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@clack/prompts": "^0.9.0", "@mosaic/mosaic": "workspace:^", "@mosaic/prdy": "workspace:^", "@mosaic/quality-rails": "workspace:^", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8ab7c23..b2b8954 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { buildPrdyCli } from '@mosaic/prdy'; import { createQualityRailsCli } from '@mosaic/quality-rails'; +import { registerAgentCommand } from './commands/agent.js'; +import { registerMissionCommand } from './commands/mission.js'; +import { registerPrdyCommand } from './commands/prdy.js'; const program = new Command(); @@ -51,8 +53,17 @@ program .option('-c, --conversation ', 'Resume a conversation by ID') .option('-m, --model ', 'Model ID to use (e.g. gpt-4o, llama3.2)') .option('-p, --provider ', 'Provider to use (e.g. openai, ollama)') + .option('--agent ', 'Connect to a specific agent') + .option('--project ', 'Scope session to project') .action( - async (opts: { gateway: string; conversation?: string; model?: string; provider?: string }) => { + async (opts: { + gateway: string; + conversation?: string; + model?: string; + provider?: string; + agent?: string; + project?: string; + }) => { const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js'); // Try loading saved session @@ -89,6 +100,50 @@ program } } + // Resolve agent ID if --agent was passed by name + let agentId: string | undefined; + let agentName: string | undefined; + if (opts.agent) { + try { + const { fetchAgentConfigs } = await import('./tui/gateway-api.js'); + const agents = await fetchAgentConfigs(opts.gateway, session.cookie); + const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent); + if (match) { + agentId = match.id; + agentName = match.name; + } else { + console.error(`Agent "${opts.agent}" not found.`); + process.exit(1); + } + } catch (err) { + console.error( + `Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + + // Resolve project ID if --project was passed by name + let projectId: string | undefined; + if (opts.project) { + try { + const { fetchProjects } = await import('./tui/gateway-api.js'); + const projects = await fetchProjects(opts.gateway, session.cookie); + const match = projects.find((p) => p.id === opts.project || p.name === opts.project); + if (match) { + projectId = match.id; + } else { + console.error(`Project "${opts.project}" not found.`); + process.exit(1); + } + } catch (err) { + console.error( + `Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + // Dynamic import to avoid loading React/Ink for other commands const { render } = await import('ink'); const React = await import('react'); @@ -101,6 +156,9 @@ program sessionCookie: session.cookie, initialModel: opts.model, initialProvider: opts.provider, + agentId, + agentName: agentName ?? undefined, + projectId, }), ); }, @@ -115,23 +173,12 @@ sessionsCmd .description('List active agent sessions') .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') .action(async (opts: { gateway: string }) => { - const { loadSession, validateSession } = await import('./auth.js'); + const { withAuth } = await import('./commands/with-auth.js'); + const auth = await withAuth(opts.gateway); const { fetchSessions } = await import('./tui/gateway-api.js'); - const session = loadSession(opts.gateway); - if (!session) { - console.error('Not signed in. Run `mosaic login` first.'); - process.exit(1); - } - - const valid = await validateSession(opts.gateway, session.cookie); - if (!valid) { - console.error('Session expired. Run `mosaic login` again.'); - process.exit(1); - } - try { - const result = await fetchSessions(opts.gateway, session.cookie); + const result = await fetchSessions(auth.gateway, auth.cookie); if (result.total === 0) { console.log('No active sessions.'); return; @@ -193,23 +240,12 @@ sessionsCmd .description('Terminate an active agent session') .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') .action(async (id: string, opts: { gateway: string }) => { - const { loadSession, validateSession } = await import('./auth.js'); + const { withAuth } = await import('./commands/with-auth.js'); + const auth = await withAuth(opts.gateway); const { deleteSession } = await import('./tui/gateway-api.js'); - const session = loadSession(opts.gateway); - if (!session) { - console.error('Not signed in. Run `mosaic login` first.'); - process.exit(1); - } - - const valid = await validateSession(opts.gateway, session.cookie); - if (!valid) { - console.error('Session expired. Run `mosaic login` again.'); - process.exit(1); - } - try { - await deleteSession(opts.gateway, session.cookie, id); + await deleteSession(auth.gateway, auth.cookie, id); console.log(`Session ${id} destroyed.`); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); @@ -217,13 +253,17 @@ sessionsCmd } }); -// ─── prdy ─────────────────────────────────────────────────────────────── +// ─── agent ───────────────────────────────────────────────────────────── -const prdyWrapper = buildPrdyCli(); -const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy'); -if (prdyCmd !== undefined) { - program.addCommand(prdyCmd as unknown as Command); -} +registerAgentCommand(program); + +// ─── mission ─────────────────────────────────────────────────────────── + +registerMissionCommand(program); + +// ─── prdy ────────────────────────────────────────────────────────────── + +registerPrdyCommand(program); // ─── quality-rails ────────────────────────────────────────────────────── diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts new file mode 100644 index 0000000..e5481ca --- /dev/null +++ b/packages/cli/src/commands/agent.ts @@ -0,0 +1,241 @@ +import type { Command } from 'commander'; +import { withAuth } from './with-auth.js'; +import { selectItem } from './select-dialog.js'; +import { + fetchAgentConfigs, + createAgentConfig, + updateAgentConfig, + deleteAgentConfig, + fetchProjects, + fetchProviders, +} from '../tui/gateway-api.js'; +import type { AgentConfigInfo } from '../tui/gateway-api.js'; + +function formatAgent(a: AgentConfigInfo): string { + const sys = a.isSystem ? ' [system]' : ''; + return `${a.name}${sys} — ${a.provider}/${a.model} (${a.status})`; +} + +function showAgentDetail(a: AgentConfigInfo) { + console.log(` ID: ${a.id}`); + console.log(` Name: ${a.name}`); + console.log(` Provider: ${a.provider}`); + console.log(` Model: ${a.model}`); + console.log(` Status: ${a.status}`); + console.log(` System: ${a.isSystem ? 'yes' : 'no'}`); + console.log(` Project: ${a.projectId ?? '—'}`); + console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`); + console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`); + console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`); + console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`); +} + +export function registerAgentCommand(program: Command) { + const cmd = program + .command('agent') + .description('Manage agent configurations') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .option('--list', 'List all agents') + .option('--new', 'Create a new agent') + .option('--show ', 'Show agent details') + .option('--update ', 'Update an agent') + .option('--delete ', 'Delete an agent') + .action( + async (opts: { + gateway: string; + list?: boolean; + new?: boolean; + show?: string; + update?: string; + delete?: string; + }) => { + const auth = await withAuth(opts.gateway); + + if (opts.list) { + return listAgents(auth.gateway, auth.cookie); + } + if (opts.new) { + return createAgentWizard(auth.gateway, auth.cookie); + } + if (opts.show) { + return showAgent(auth.gateway, auth.cookie, opts.show); + } + if (opts.update) { + return updateAgentWizard(auth.gateway, auth.cookie, opts.update); + } + if (opts.delete) { + return deleteAgent(auth.gateway, auth.cookie, opts.delete); + } + + // Default: interactive select + return interactiveSelect(auth.gateway, auth.cookie); + }, + ); + + return cmd; +} + +async function resolveAgent( + gateway: string, + cookie: string, + idOrName: string, +): Promise { + const agents = await fetchAgentConfigs(gateway, cookie); + return agents.find((a) => a.id === idOrName || a.name === idOrName); +} + +async function listAgents(gateway: string, cookie: string) { + const agents = await fetchAgentConfigs(gateway, cookie); + if (agents.length === 0) { + console.log('No agents found.'); + return; + } + console.log(`Agents (${agents.length}):\n`); + for (const a of agents) { + const sys = a.isSystem ? ' [system]' : ''; + const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : ''; + console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`); + } +} + +async function showAgent(gateway: string, cookie: string, idOrName: string) { + const agent = await resolveAgent(gateway, cookie, idOrName); + if (!agent) { + console.error(`Agent "${idOrName}" not found.`); + process.exit(1); + } + showAgentDetail(agent); +} + +async function interactiveSelect(gateway: string, cookie: string) { + const agents = await fetchAgentConfigs(gateway, cookie); + const selected = await selectItem(agents, { + message: 'Select an agent:', + render: formatAgent, + emptyMessage: 'No agents found. Create one with `mosaic agent --new`.', + }); + if (selected) { + showAgentDetail(selected); + } +} + +async function createAgentWizard(gateway: string, cookie: string) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + try { + const name = await ask('Agent name: '); + if (!name.trim()) { + console.error('Name is required.'); + return; + } + + // Project selection + const projects = await fetchProjects(gateway, cookie); + let projectId: string | undefined; + if (projects.length > 0) { + const selected = await selectItem(projects, { + message: 'Assign to project (optional):', + render: (p) => `${p.name} (${p.status})`, + }); + if (selected) projectId = selected.id; + } + + // Provider / model selection + const providers = await fetchProviders(gateway, cookie); + let provider = 'default'; + let model = 'default'; + + if (providers.length > 0) { + const allModels = providers.flatMap((p) => + p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })), + ); + if (allModels.length > 0) { + const selected = await selectItem(allModels, { + message: 'Select model:', + render: (m) => m.label, + }); + if (selected) { + provider = selected.provider; + model = selected.model; + } + } + } + + const systemPrompt = await ask('System prompt (optional, press Enter to skip): '); + + const agent = await createAgentConfig(gateway, cookie, { + name: name.trim(), + provider, + model, + projectId, + systemPrompt: systemPrompt.trim() || undefined, + }); + + console.log(`\nAgent "${agent.name}" created (${agent.id}).`); + } finally { + rl.close(); + } +} + +async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) { + const agent = await resolveAgent(gateway, cookie, idOrName); + if (!agent) { + console.error(`Agent "${idOrName}" not found.`); + process.exit(1); + } + + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + try { + console.log(`Updating agent: ${agent.name}\n`); + + const name = await ask(`Name [${agent.name}]: `); + const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `); + + const updates: Record = {}; + if (name.trim()) updates['name'] = name.trim(); + if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim(); + + if (Object.keys(updates).length === 0) { + console.log('No changes.'); + return; + } + + const updated = await updateAgentConfig(gateway, cookie, agent.id, updates); + console.log(`\nAgent "${updated.name}" updated.`); + } finally { + rl.close(); + } +} + +async function deleteAgent(gateway: string, cookie: string, idOrName: string) { + const agent = await resolveAgent(gateway, cookie, idOrName); + if (!agent) { + console.error(`Agent "${idOrName}" not found.`); + process.exit(1); + } + + if (agent.isSystem) { + console.error('Cannot delete system agents.'); + process.exit(1); + } + + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve), + ); + rl.close(); + + if (answer.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + + await deleteAgentConfig(gateway, cookie, agent.id); + console.log(`Agent "${agent.name}" deleted.`); +} diff --git a/packages/cli/src/commands/mission.ts b/packages/cli/src/commands/mission.ts new file mode 100644 index 0000000..1f54ebe --- /dev/null +++ b/packages/cli/src/commands/mission.ts @@ -0,0 +1,385 @@ +import type { Command } from 'commander'; +import { withAuth } from './with-auth.js'; +import { selectItem } from './select-dialog.js'; +import { + fetchMissions, + fetchMission, + createMission, + updateMission, + fetchMissionTasks, + createMissionTask, + updateMissionTask, + fetchProjects, +} from '../tui/gateway-api.js'; +import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js'; + +function formatMission(m: MissionInfo): string { + return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`; +} + +function showMissionDetail(m: MissionInfo) { + console.log(` ID: ${m.id}`); + console.log(` Name: ${m.name}`); + console.log(` Status: ${m.status}`); + console.log(` Phase: ${m.phase ?? '—'}`); + console.log(` Project: ${m.projectId ?? '—'}`); + console.log(` Description: ${m.description ?? '—'}`); + console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`); +} + +function showTaskDetail(t: MissionTaskInfo) { + console.log(` ID: ${t.id}`); + console.log(` Status: ${t.status}`); + console.log(` Description: ${t.description ?? '—'}`); + console.log(` Notes: ${t.notes ?? '—'}`); + console.log(` PR: ${t.pr ?? '—'}`); + console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`); +} + +export function registerMissionCommand(program: Command) { + const cmd = program + .command('mission') + .description('Manage missions') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .option('--list', 'List all missions') + .option('--init', 'Create a new mission') + .option('--plan ', 'Run PRD wizard for a mission') + .option('--update ', 'Update a mission') + .option('--project ', 'Scope to project') + .argument('[id]', 'Show mission detail by ID') + .action( + async ( + id: string | undefined, + opts: { + gateway: string; + list?: boolean; + init?: boolean; + plan?: string; + update?: string; + project?: string; + }, + ) => { + const auth = await withAuth(opts.gateway); + + if (opts.list) { + return listMissions(auth.gateway, auth.cookie); + } + if (opts.init) { + return initMission(auth.gateway, auth.cookie); + } + if (opts.plan) { + return planMission(auth.gateway, auth.cookie, opts.plan, opts.project); + } + if (opts.update) { + return updateMissionWizard(auth.gateway, auth.cookie, opts.update); + } + if (id) { + return showMission(auth.gateway, auth.cookie, id); + } + + // Default: interactive select + return interactiveSelect(auth.gateway, auth.cookie); + }, + ); + + // Task subcommand + cmd + .command('task') + .description('Manage mission tasks') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .option('--list', 'List tasks for a mission') + .option('--new', 'Create a task') + .option('--update ', 'Update a task') + .option('--mission ', 'Mission ID or name') + .argument('[taskId]', 'Show task detail') + .action( + async ( + taskId: string | undefined, + taskOpts: { + gateway: string; + list?: boolean; + new?: boolean; + update?: string; + mission?: string; + }, + ) => { + const auth = await withAuth(taskOpts.gateway); + + const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission); + if (!missionId) return; + + if (taskOpts.list) { + return listTasks(auth.gateway, auth.cookie, missionId); + } + if (taskOpts.new) { + return createTaskWizard(auth.gateway, auth.cookie, missionId); + } + if (taskOpts.update) { + return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update); + } + if (taskId) { + return showTask(auth.gateway, auth.cookie, missionId, taskId); + } + + return listTasks(auth.gateway, auth.cookie, missionId); + }, + ); + + return cmd; +} + +async function resolveMissionByName( + gateway: string, + cookie: string, + idOrName: string, +): Promise { + const missions = await fetchMissions(gateway, cookie); + return missions.find((m) => m.id === idOrName || m.name === idOrName); +} + +async function resolveMissionId( + gateway: string, + cookie: string, + idOrName?: string, +): Promise { + if (idOrName) { + const mission = await resolveMissionByName(gateway, cookie, idOrName); + if (!mission) { + console.error(`Mission "${idOrName}" not found.`); + return undefined; + } + return mission.id; + } + + // Interactive select + const missions = await fetchMissions(gateway, cookie); + const selected = await selectItem(missions, { + message: 'Select a mission:', + render: formatMission, + emptyMessage: 'No missions found. Create one with `mosaic mission --init`.', + }); + return selected?.id; +} + +async function listMissions(gateway: string, cookie: string) { + const missions = await fetchMissions(gateway, cookie); + if (missions.length === 0) { + console.log('No missions found.'); + return; + } + console.log(`Missions (${missions.length}):\n`); + for (const m of missions) { + const phase = m.phase ? ` [${m.phase}]` : ''; + console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`); + } +} + +async function showMission(gateway: string, cookie: string, id: string) { + try { + const mission = await fetchMission(gateway, cookie, id); + showMissionDetail(mission); + } catch { + // Try resolving by name + const m = await resolveMissionByName(gateway, cookie, id); + if (!m) { + console.error(`Mission "${id}" not found.`); + process.exit(1); + } + showMissionDetail(m); + } +} + +async function interactiveSelect(gateway: string, cookie: string) { + const missions = await fetchMissions(gateway, cookie); + const selected = await selectItem(missions, { + message: 'Select a mission:', + render: formatMission, + emptyMessage: 'No missions found. Create one with `mosaic mission --init`.', + }); + if (selected) { + showMissionDetail(selected); + } +} + +async function initMission(gateway: string, cookie: string) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + try { + const name = await ask('Mission name: '); + if (!name.trim()) { + console.error('Name is required.'); + return; + } + + // Project selection + const projects = await fetchProjects(gateway, cookie); + let projectId: string | undefined; + if (projects.length > 0) { + const selected = await selectItem(projects, { + message: 'Assign to project (required):', + render: (p) => `${p.name} (${p.status})`, + emptyMessage: 'No projects found.', + }); + if (selected) projectId = selected.id; + } + + const description = await ask('Description (optional): '); + + const mission = await createMission(gateway, cookie, { + name: name.trim(), + projectId, + description: description.trim() || undefined, + status: 'planning', + }); + + console.log(`\nMission "${mission.name}" created (${mission.id}).`); + } finally { + rl.close(); + } +} + +async function planMission( + gateway: string, + cookie: string, + idOrName: string, + _projectIdOrName?: string, +) { + const mission = await resolveMissionByName(gateway, cookie, idOrName); + if (!mission) { + console.error(`Mission "${idOrName}" not found.`); + process.exit(1); + } + + console.log(`Planning mission: ${mission.name}\n`); + + try { + const { runPrdWizard } = await import('@mosaic/prdy'); + await runPrdWizard({ + name: mission.name, + projectPath: process.cwd(), + interactive: true, + }); + } catch (err) { + console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} + +async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) { + const mission = await resolveMissionByName(gateway, cookie, idOrName); + if (!mission) { + console.error(`Mission "${idOrName}" not found.`); + process.exit(1); + } + + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + try { + console.log(`Updating mission: ${mission.name}\n`); + + const name = await ask(`Name [${mission.name}]: `); + const description = await ask(`Description [${mission.description ?? 'none'}]: `); + const status = await ask(`Status [${mission.status}]: `); + + const updates: Record = {}; + if (name.trim()) updates['name'] = name.trim(); + if (description.trim()) updates['description'] = description.trim(); + if (status.trim()) updates['status'] = status.trim(); + + if (Object.keys(updates).length === 0) { + console.log('No changes.'); + return; + } + + const updated = await updateMission(gateway, cookie, mission.id, updates); + console.log(`\nMission "${updated.name}" updated.`); + } finally { + rl.close(); + } +} + +// ── Task operations ── + +async function listTasks(gateway: string, cookie: string, missionId: string) { + const tasks = await fetchMissionTasks(gateway, cookie, missionId); + if (tasks.length === 0) { + console.log('No tasks found.'); + return; + } + console.log(`Tasks (${tasks.length}):\n`); + for (const t of tasks) { + const desc = t.description ? ` — ${t.description.slice(0, 60)}` : ''; + console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`); + } +} + +async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) { + const tasks = await fetchMissionTasks(gateway, cookie, missionId); + const task = tasks.find((t) => t.id === taskId); + if (!task) { + console.error(`Task "${taskId}" not found.`); + process.exit(1); + } + showTaskDetail(task); +} + +async function createTaskWizard(gateway: string, cookie: string, missionId: string) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + try { + const description = await ask('Task description: '); + if (!description.trim()) { + console.error('Description is required.'); + return; + } + + const status = await ask('Status [not-started]: '); + + const task = await createMissionTask(gateway, cookie, missionId, { + description: description.trim(), + status: status.trim() || 'not-started', + }); + + console.log(`\nTask created (${task.id}).`); + } finally { + rl.close(); + } +} + +async function updateTaskWizard( + gateway: string, + cookie: string, + missionId: string, + taskId: string, +) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + try { + const status = await ask('New status: '); + const notes = await ask('Notes (optional): '); + const pr = await ask('PR (optional): '); + + const updates: Record = {}; + if (status.trim()) updates['status'] = status.trim(); + if (notes.trim()) updates['notes'] = notes.trim(); + if (pr.trim()) updates['pr'] = pr.trim(); + + if (Object.keys(updates).length === 0) { + console.log('No changes.'); + return; + } + + const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates); + console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`); + } finally { + rl.close(); + } +} diff --git a/packages/cli/src/commands/prdy.ts b/packages/cli/src/commands/prdy.ts new file mode 100644 index 0000000..a5807c8 --- /dev/null +++ b/packages/cli/src/commands/prdy.ts @@ -0,0 +1,55 @@ +import type { Command } from 'commander'; +import { withAuth } from './with-auth.js'; +import { fetchProjects } from '../tui/gateway-api.js'; + +export function registerPrdyCommand(program: Command) { + const cmd = program + .command('prdy') + .description('PRD wizard — create and manage Product Requirement Documents') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .option('--init [name]', 'Create a new PRD') + .option('--update [name]', 'Update an existing PRD') + .option('--project ', 'Scope to project') + .action( + async (opts: { + gateway: string; + init?: string | boolean; + update?: string | boolean; + project?: string; + }) => { + // Detect project context when --project flag is provided + if (opts.project) { + try { + const auth = await withAuth(opts.gateway); + const projects = await fetchProjects(auth.gateway, auth.cookie); + const match = projects.find((p) => p.id === opts.project || p.name === opts.project); + if (match) { + console.log(`Project context: ${match.name} (${match.id})\n`); + } + } catch { + // Gateway not available — proceed without project context + } + } + + try { + const { runPrdWizard } = await import('@mosaic/prdy'); + const name = + typeof opts.init === 'string' + ? opts.init + : typeof opts.update === 'string' + ? opts.update + : 'untitled'; + await runPrdWizard({ + name, + projectPath: process.cwd(), + interactive: true, + }); + } catch (err) { + console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }, + ); + + return cmd; +} diff --git a/packages/cli/src/commands/select-dialog.ts b/packages/cli/src/commands/select-dialog.ts new file mode 100644 index 0000000..f060947 --- /dev/null +++ b/packages/cli/src/commands/select-dialog.ts @@ -0,0 +1,58 @@ +/** + * Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list. + */ +export async function selectItem( + items: T[], + opts: { + message: string; + render: (item: T) => string; + emptyMessage?: string; + }, +): Promise { + if (items.length === 0) { + console.log(opts.emptyMessage ?? 'No items found.'); + return undefined; + } + + const isTTY = process.stdin.isTTY; + + if (isTTY) { + try { + const { select } = await import('@clack/prompts'); + const result = await select({ + message: opts.message, + options: items.map((item, i) => ({ + value: i, + label: opts.render(item), + })), + }); + + if (typeof result === 'symbol') { + return undefined; + } + + return items[result as number]; + } catch { + // Fall through to non-interactive + } + } + + // Non-interactive: display numbered list and read a number + console.log(`\n${opts.message}\n`); + for (let i = 0; i < items.length; i++) { + console.log(` ${i + 1}. ${opts.render(items[i]!)}`); + } + + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => rl.question('\nSelect: ', resolve)); + rl.close(); + + const index = parseInt(answer, 10) - 1; + if (isNaN(index) || index < 0 || index >= items.length) { + console.error('Invalid selection.'); + return undefined; + } + + return items[index]; +} diff --git a/packages/cli/src/commands/with-auth.ts b/packages/cli/src/commands/with-auth.ts new file mode 100644 index 0000000..936da7b --- /dev/null +++ b/packages/cli/src/commands/with-auth.ts @@ -0,0 +1,29 @@ +import type { AuthResult } from '../auth.js'; + +export interface AuthContext { + gateway: string; + session: AuthResult; + cookie: string; +} + +/** + * Load and validate the user's auth session. + * Exits with an error message if not signed in or session expired. + */ +export async function withAuth(gateway: string): Promise { + const { loadSession, validateSession } = await import('../auth.js'); + + const session = loadSession(gateway); + if (!session) { + console.error('Not signed in. Run `mosaic login` first.'); + process.exit(1); + } + + const valid = await validateSession(gateway, session.cookie); + if (!valid) { + console.error('Session expired. Run `mosaic login` again.'); + process.exit(1); + } + + return { gateway, session, cookie: session.cookie }; +} diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 9effec4..046aa92 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -19,6 +19,9 @@ export interface TuiAppProps { sessionCookie?: string; initialModel?: string; initialProvider?: string; + agentId?: string; + agentName?: string; + projectId?: string; } export function TuiApp({ @@ -27,6 +30,9 @@ export function TuiApp({ sessionCookie, initialModel, initialProvider, + agentId, + agentName, + projectId: _projectId, }: TuiAppProps) { const { exit } = useApp(); const gitInfo = useGitInfo(); @@ -38,6 +44,7 @@ export function TuiApp({ initialConversationId: conversationId, initialModel, initialProvider, + agentId, }); const conversations = useConversations({ gatewayUrl, sessionCookie }); @@ -211,7 +218,7 @@ export function TuiApp({ modelName={socket.modelName} thinkingLevel={socket.thinkingLevel} contextWindow={socket.tokenUsage.contextWindow} - agentName="default" + agentName={agentName ?? 'default'} connected={socket.connected} connecting={socket.connecting} /> diff --git a/packages/cli/src/tui/gateway-api.ts b/packages/cli/src/tui/gateway-api.ts index 787f158..8c63bd7 100644 --- a/packages/cli/src/tui/gateway-api.ts +++ b/packages/cli/src/tui/gateway-api.ts @@ -1,5 +1,5 @@ /** - * Minimal gateway REST API client for the TUI. + * Minimal gateway REST API client for the TUI and CLI commands. */ export interface ModelInfo { @@ -30,10 +30,88 @@ export interface SessionListResult { total: number; } -/** - * Fetch the list of available models from the gateway. - * Returns an empty array on network or auth errors so the TUI can still function. - */ +// ── Agent Config types ── + +export interface AgentConfigInfo { + id: string; + name: string; + provider: string; + model: string; + status: string; + projectId: string | null; + ownerId: string | null; + systemPrompt: string | null; + allowedTools: string[] | null; + skills: string[] | null; + isSystem: boolean; + config: Record | null; + createdAt: string; + updatedAt: string; +} + +// ── Project types ── + +export interface ProjectInfo { + id: string; + name: string; + description: string | null; + status: string; + ownerId: string | null; + createdAt: string; + updatedAt: string; +} + +// ── Mission types ── + +export interface MissionInfo { + id: string; + name: string; + description: string | null; + status: string; + projectId: string | null; + userId: string | null; + phase: string | null; + milestones: Record[] | null; + config: Record | null; + createdAt: string; + updatedAt: string; +} + +// ── Mission Task types ── + +export interface MissionTaskInfo { + id: string; + missionId: string; + taskId: string | null; + userId: string; + status: string; + description: string | null; + notes: string | null; + pr: string | null; + createdAt: string; + updatedAt: string; +} + +// ── Helpers ── + +function headers(sessionCookie: string, gatewayUrl: string) { + return { Cookie: sessionCookie, Origin: gatewayUrl }; +} + +function jsonHeaders(sessionCookie: string, gatewayUrl: string) { + return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' }; +} + +async function handleResponse(res: Response, errorPrefix: string): Promise { + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`${errorPrefix} (${res.status}): ${body}`); + } + return (await res.json()) as T; +} + +// ── Provider / Model endpoints ── + export async function fetchAvailableModels( gatewayUrl: string, sessionCookie?: string, @@ -53,10 +131,6 @@ export async function fetchAvailableModels( } } -/** - * Fetch the list of providers (with their models) from the gateway. - * Returns an empty array on network or auth errors. - */ export async function fetchProviders( gatewayUrl: string, sessionCookie?: string, @@ -76,28 +150,18 @@ export async function fetchProviders( } } -/** - * Fetch the list of active agent sessions from the gateway. - * Throws on network or auth errors. - */ +// ── Session endpoints ── + export async function fetchSessions( gatewayUrl: string, sessionCookie: string, ): Promise { const res = await fetch(`${gatewayUrl}/api/sessions`, { - headers: { Cookie: sessionCookie, Origin: gatewayUrl }, + headers: headers(sessionCookie, gatewayUrl), }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Failed to list sessions (${res.status}): ${body}`); - } - return (await res.json()) as SessionListResult; + return handleResponse(res, 'Failed to list sessions'); } -/** - * Destroy (terminate) an agent session on the gateway. - * Throws on network or auth errors. - */ export async function deleteSession( gatewayUrl: string, sessionCookie: string, @@ -105,10 +169,220 @@ export async function deleteSession( ): Promise { const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', - headers: { Cookie: sessionCookie, Origin: gatewayUrl }, + headers: headers(sessionCookie, gatewayUrl), }); if (!res.ok && res.status !== 204) { const body = await res.text().catch(() => ''); throw new Error(`Failed to destroy session (${res.status}): ${body}`); } } + +// ── Agent Config endpoints ── + +export async function fetchAgentConfigs( + gatewayUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/agents`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to list agents'); +} + +export async function fetchAgentConfig( + gatewayUrl: string, + sessionCookie: string, + id: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to get agent'); +} + +export async function createAgentConfig( + gatewayUrl: string, + sessionCookie: string, + data: { + name: string; + provider: string; + model: string; + projectId?: string; + systemPrompt?: string; + allowedTools?: string[]; + skills?: string[]; + config?: Record; + }, +): Promise { + const res = await fetch(`${gatewayUrl}/api/agents`, { + method: 'POST', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to create agent'); +} + +export async function updateAgentConfig( + gatewayUrl: string, + sessionCookie: string, + id: string, + data: Record, +): Promise { + const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to update agent'); +} + +export async function deleteAgentConfig( + gatewayUrl: string, + sessionCookie: string, + id: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: headers(sessionCookie, gatewayUrl), + }); + if (!res.ok && res.status !== 204) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to delete agent (${res.status}): ${body}`); + } +} + +// ── Project endpoints ── + +export async function fetchProjects( + gatewayUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/projects`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to list projects'); +} + +// ── Mission endpoints ── + +export async function fetchMissions( + gatewayUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to list missions'); +} + +export async function fetchMission( + gatewayUrl: string, + sessionCookie: string, + id: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to get mission'); +} + +export async function createMission( + gatewayUrl: string, + sessionCookie: string, + data: { + name: string; + description?: string; + projectId?: string; + status?: string; + phase?: string; + milestones?: Record[]; + config?: Record; + }, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions`, { + method: 'POST', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to create mission'); +} + +export async function updateMission( + gatewayUrl: string, + sessionCookie: string, + id: string, + data: Record, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to update mission'); +} + +export async function deleteMission( + gatewayUrl: string, + sessionCookie: string, + id: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: headers(sessionCookie, gatewayUrl), + }); + if (!res.ok && res.status !== 204) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to delete mission (${res.status}): ${body}`); + } +} + +// ── Mission Task endpoints ── + +export async function fetchMissionTasks( + gatewayUrl: string, + sessionCookie: string, + missionId: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to list mission tasks'); +} + +export async function createMissionTask( + gatewayUrl: string, + sessionCookie: string, + missionId: string, + data: { + description?: string; + status?: string; + notes?: string; + pr?: string; + taskId?: string; + }, +): Promise { + const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, { + method: 'POST', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }); + return handleResponse(res, 'Failed to create mission task'); +} + +export async function updateMissionTask( + gatewayUrl: string, + sessionCookie: string, + missionId: string, + taskId: string, + data: Record, +): Promise { + const res = await fetch( + `${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`, + { + method: 'PATCH', + headers: jsonHeaders(sessionCookie, gatewayUrl), + body: JSON.stringify(data), + }, + ); + return handleResponse(res, 'Failed to update mission task'); +} diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 7ce9d55..93229fa 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -43,6 +43,7 @@ export interface UseSocketOptions { initialConversationId?: string; initialModel?: string; initialProvider?: string; + agentId?: string; } export interface UseSocketReturn { @@ -80,7 +81,14 @@ const EMPTY_USAGE: TokenUsage = { }; export function useSocket(opts: UseSocketOptions): UseSocketReturn { - const { gatewayUrl, sessionCookie, initialConversationId, initialModel, initialProvider } = opts; + const { + gatewayUrl, + sessionCookie, + initialConversationId, + initialModel, + initialProvider, + agentId, + } = opts; const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(true); @@ -231,6 +239,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { content, ...(initialProvider ? { provider: initialProvider } : {}), ...(initialModel ? { modelId: initialModel } : {}), + ...(agentId ? { agentId } : {}), }); }, [conversationId, isStreaming], diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7e65b83..95fc164 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -190,18 +190,32 @@ export const events = pgTable( (t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)], ); -export const agents = pgTable('agents', { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - provider: text('provider').notNull(), - model: text('model').notNull(), - status: text('status', { enum: ['idle', 'active', 'error', 'offline'] }) - .notNull() - .default('idle'), - config: jsonb('config'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +export const agents = pgTable( + 'agents', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + provider: text('provider').notNull(), + model: text('model').notNull(), + status: text('status', { enum: ['idle', 'active', 'error', 'offline'] }) + .notNull() + .default('idle'), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + systemPrompt: text('system_prompt'), + allowedTools: jsonb('allowed_tools').$type(), + skills: jsonb('skills').$type(), + isSystem: boolean('is_system').notNull().default(false), + config: jsonb('config'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + index('agents_project_id_idx').on(t.projectId), + index('agents_owner_id_idx').on(t.ownerId), + index('agents_is_system_idx').on(t.isSystem), + ], +); export const tickets = pgTable( 'tickets', @@ -243,6 +257,7 @@ export const conversations = pgTable( .notNull() .references(() => users.id, { onDelete: 'cascade' }), projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), + agentId: uuid('agent_id').references(() => agents.id, { onDelete: 'set null' }), archived: boolean('archived').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), @@ -250,6 +265,7 @@ export const conversations = pgTable( (t) => [ index('conversations_user_id_idx').on(t.userId), index('conversations_project_id_idx').on(t.projectId), + index('conversations_agent_id_idx').on(t.agentId), index('conversations_archived_idx').on(t.archived), ], ); diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 0cf999d..b8de5d7 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -64,6 +64,7 @@ export interface ChatMessagePayload { content: string; provider?: string; modelId?: string; + agentId?: string; } /** Session info pushed when session is created or model changes */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e3bffe..4b6c833 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,9 @@ importers: packages/cli: dependencies: + '@clack/prompts': + specifier: ^0.9.0 + version: 0.9.1 '@mosaic/mosaic': specifier: workspace:^ version: link:../mosaic