feat(cli): command architecture — agents, missions, gateway-aware prdy (#158)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #158.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||||
import { MissionsController } from '../missions/missions.controller.js';
|
import { MissionsController } from '../missions/missions.controller.js';
|
||||||
@@ -25,12 +25,21 @@ function createBrain() {
|
|||||||
},
|
},
|
||||||
missions: {
|
missions: {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findAllByUser: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
|
findByIdAndUser: vi.fn(),
|
||||||
findByProject: vi.fn(),
|
findByProject: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
},
|
},
|
||||||
|
missionTasks: {
|
||||||
|
findByMissionAndUser: vi.fn(),
|
||||||
|
findByIdAndUser: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
findById: 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();
|
const brain = createBrain();
|
||||||
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
// findByIdAndUser returns undefined when the mission doesn't belong to the user
|
||||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
|
||||||
const controller = new MissionsController(brain as never);
|
const controller = new MissionsController(brain as never);
|
||||||
|
|
||||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
97
apps/gateway/src/agent/agent-config.dto.ts
Normal file
97
apps/gateway/src/agent/agent-config.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | null;
|
||||||
|
}
|
||||||
84
apps/gateway/src/agent/agent-configs.controller.ts
Normal file
84
apps/gateway/src/agent/agent-configs.controller.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { RoutingService } from './routing.service.js';
|
|||||||
import { SkillLoaderService } from './skill-loader.service.js';
|
import { SkillLoaderService } from './skill-loader.service.js';
|
||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
@@ -13,7 +14,7 @@ import { SkillsModule } from '../skills/skills.module.js';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController],
|
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export interface AgentSessionOptions {
|
|||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
||||||
isAdmin?: boolean;
|
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 {
|
export interface AgentSession {
|
||||||
@@ -146,16 +152,39 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: AgentSessionOptions,
|
options?: AgentSessionOptions,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
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 providerName = model?.provider ?? 'default';
|
||||||
const modelId = model?.id ?? 'default';
|
const modelId = model?.id ?? 'default';
|
||||||
|
|
||||||
// Resolve sandbox directory: option > env var > process.cwd()
|
// Resolve sandbox directory: option > env var > process.cwd()
|
||||||
const sandboxDir =
|
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
|
// 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(
|
this.logger.log(
|
||||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
`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
|
// 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 =
|
const appendSystemPrompt =
|
||||||
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
modelId: data.modelId,
|
modelId: data.modelId,
|
||||||
|
agentConfigId: data.agentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { CoordService } from './coord.service.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). */
|
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||||
function findMonorepoRoot(start: string): string {
|
function findMonorepoRoot(start: string): string {
|
||||||
@@ -57,13 +44,15 @@ function resolveAndValidatePath(raw: string | undefined): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-based coord endpoints for agent tool consumption.
|
||||||
|
* DB-backed mission CRUD has moved to MissionsController at /api/missions.
|
||||||
|
*/
|
||||||
@Controller('api/coord')
|
@Controller('api/coord')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class CoordController {
|
export class CoordController {
|
||||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||||
|
|
||||||
// ── File-based coord endpoints (legacy) ──
|
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||||
@@ -85,121 +74,4 @@ export class CoordController {
|
|||||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||||
return detail;
|
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaic/brain';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import {
|
import {
|
||||||
loadMission,
|
loadMission,
|
||||||
getMissionStatus,
|
getMissionStatus,
|
||||||
@@ -14,12 +12,14 @@ import {
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
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()
|
@Injectable()
|
||||||
export class CoordService {
|
export class CoordService {
|
||||||
private readonly logger = new Logger(CoordService.name);
|
private readonly logger = new Logger(CoordService.name);
|
||||||
|
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
|
||||||
|
|
||||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||||
try {
|
try {
|
||||||
return await loadMission(projectPath);
|
return await loadMission(projectPath);
|
||||||
@@ -74,68 +74,4 @@ export class CoordService {
|
|||||||
return [];
|
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<Brain['missions']['create']>[0]) {
|
|
||||||
return this.brain.missions.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDbMission(
|
|
||||||
id: string,
|
|
||||||
userId: string,
|
|
||||||
data: Parameters<Brain['missions']['update']>[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<Brain['missionTasks']['create']>[0]) {
|
|
||||||
return this.brain.missionTasks.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMissionTask(
|
|
||||||
id: string,
|
|
||||||
userId: string,
|
|
||||||
data: Parameters<Brain['missionTasks']['update']>[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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -17,33 +16,42 @@ import type { Brain } from '@mosaic/brain';
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { assertOwner } from '../auth/resource-ownership.js';
|
import {
|
||||||
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
CreateMissionDto,
|
||||||
|
UpdateMissionDto,
|
||||||
|
CreateMissionTaskDto,
|
||||||
|
UpdateMissionTaskDto,
|
||||||
|
} from './missions.dto.js';
|
||||||
|
|
||||||
@Controller('api/missions')
|
@Controller('api/missions')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class MissionsController {
|
export class MissionsController {
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||||
|
|
||||||
|
// ── Missions CRUD (user-scoped) ──
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list(@CurrentUser() user: { id: string }) {
|
||||||
return this.brain.missions.findAll();
|
return this.brain.missions.findAllByUser(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
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()
|
@Post()
|
||||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
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({
|
return this.brain.missions.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
projectId: dto.projectId,
|
projectId: dto.projectId,
|
||||||
|
userId: user.id,
|
||||||
|
phase: dto.phase,
|
||||||
|
milestones: dto.milestones,
|
||||||
|
config: dto.config,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,10 +62,8 @@ export class MissionsController {
|
|||||||
@Body() dto: UpdateMissionDto,
|
@Body() dto: UpdateMissionDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
await this.getOwnedMission(id, user.id);
|
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||||
if (dto.projectId) {
|
if (!existing) throw new NotFoundException('Mission not found');
|
||||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
|
||||||
}
|
|
||||||
const mission = await this.brain.missions.update(id, dto);
|
const mission = await this.brain.missions.update(id, dto);
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
return mission;
|
return mission;
|
||||||
@@ -66,33 +72,81 @@ export class MissionsController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
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);
|
const deleted = await this.brain.missions.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Mission not found');
|
if (!deleted) throw new NotFoundException('Mission not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOwnedMission(id: string, userId: string) {
|
// ── Mission Tasks sub-routes ──
|
||||||
const mission = await this.brain.missions.findById(id);
|
|
||||||
|
@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');
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
await this.getOwnedProject(mission.projectId, userId, 'Mission');
|
return this.brain.missionTasks.findByMissionAndUser(missionId, user.id);
|
||||||
return mission;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOwnedProject(
|
@Get(':missionId/tasks/:taskId')
|
||||||
projectId: string | null | undefined,
|
async getTask(
|
||||||
userId: string,
|
@Param('missionId') missionId: string,
|
||||||
resourceName: string,
|
@Param('taskId') taskId: string,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
if (!projectId) {
|
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
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);
|
@Post(':missionId/tasks')
|
||||||
if (!project) {
|
async createTask(
|
||||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
@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);
|
@Patch(':missionId/tasks/:taskId')
|
||||||
return project;
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
|
||||||
|
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||||
|
|
||||||
export class CreateMissionDto {
|
export class CreateMissionDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -19,6 +20,19 @@ export class CreateMissionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(missionStatuses)
|
@IsIn(missionStatuses)
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
phase?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
milestones?: Record<string, unknown>[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
config?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateMissionDto {
|
export class UpdateMissionDto {
|
||||||
@@ -40,7 +54,70 @@ export class UpdateMissionDto {
|
|||||||
@IsIn(missionStatuses)
|
@IsIn(missionStatuses)
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
phase?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
milestones?: Record<string, unknown>[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
|
|||||||
156
docs/TASKS.md
156
docs/TASKS.md
@@ -2,80 +2,82 @@
|
|||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | milestone | description | pr | notes |
|
||||||
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- |
|
| ------ | ----------- | --------- | --------------------------------------------------------------------- | ---- | ------------- |
|
||||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
| 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-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-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-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-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
|
||||||
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
|
| 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-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 |
|
| 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-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-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-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-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
|
||||||
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
|
| 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-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
|
||||||
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
|
||||||
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
|
| 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 |
|
| 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-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-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-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-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-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-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 |
|
| 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-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-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-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-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-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-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
|
||||||
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
|
| 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 |
|
| 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-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
|
||||||
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
|
| 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-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-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
|
||||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
| 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-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-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-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-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 |
|
| 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-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-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-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-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-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
| 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-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-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-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-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-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-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-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-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-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-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-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-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 |
|
| 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-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 |
|
| 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-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-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-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 |
|
| 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-005 | in-progress | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | — | |
|
||||||
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
| P8-006 | not-started | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | — | |
|
||||||
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
| 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 |
|
||||||
|
|||||||
58
packages/brain/src/agents.ts
Normal file
58
packages/brain/src/agents.ts
Normal file
@@ -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<Agent[]> {
|
||||||
|
return db.select().from(agents);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Agent | undefined> {
|
||||||
|
const rows = await db.select().from(agents).where(eq(agents.id, id));
|
||||||
|
return rows[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async findByName(name: string): Promise<Agent | undefined> {
|
||||||
|
const rows = await db.select().from(agents).where(eq(agents.name, name));
|
||||||
|
return rows[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async findByProject(projectId: string): Promise<Agent[]> {
|
||||||
|
return db.select().from(agents).where(eq(agents.projectId, projectId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async findSystem(): Promise<Agent[]> {
|
||||||
|
return db.select().from(agents).where(eq(agents.isSystem, true));
|
||||||
|
},
|
||||||
|
|
||||||
|
async findAccessible(ownerId: string): Promise<Agent[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(or(eq(agents.ownerId, ownerId), eq(agents.isSystem, true)));
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: NewAgent): Promise<Agent> {
|
||||||
|
const rows = await db.insert(agents).values(data).returning();
|
||||||
|
return rows[0]!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<NewAgent>): Promise<Agent | undefined> {
|
||||||
|
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<boolean> {
|
||||||
|
const rows = await db.delete(agents).where(eq(agents.id, id)).returning();
|
||||||
|
return rows.length > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentsRepo = ReturnType<typeof createAgentsRepo>;
|
||||||
@@ -4,6 +4,7 @@ import { createMissionsRepo, type MissionsRepo } from './missions.js';
|
|||||||
import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
|
import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
|
||||||
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
import { createTasksRepo, type TasksRepo } from './tasks.js';
|
||||||
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
||||||
|
import { createAgentsRepo, type AgentsRepo } from './agents.js';
|
||||||
|
|
||||||
export interface Brain {
|
export interface Brain {
|
||||||
projects: ProjectsRepo;
|
projects: ProjectsRepo;
|
||||||
@@ -11,6 +12,7 @@ export interface Brain {
|
|||||||
missionTasks: MissionTasksRepo;
|
missionTasks: MissionTasksRepo;
|
||||||
tasks: TasksRepo;
|
tasks: TasksRepo;
|
||||||
conversations: ConversationsRepo;
|
conversations: ConversationsRepo;
|
||||||
|
agents: AgentsRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBrain(db: Db): Brain {
|
export function createBrain(db: Db): Brain {
|
||||||
@@ -20,5 +22,6 @@ export function createBrain(db: Db): Brain {
|
|||||||
missionTasks: createMissionTasksRepo(db),
|
missionTasks: createMissionTasksRepo(db),
|
||||||
tasks: createTasksRepo(db),
|
tasks: createTasksRepo(db),
|
||||||
conversations: createConversationsRepo(db),
|
conversations: createConversationsRepo(db),
|
||||||
|
agents: createAgentsRepo(db),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ export {
|
|||||||
type Message,
|
type Message,
|
||||||
type NewMessage,
|
type NewMessage,
|
||||||
} from './conversations.js';
|
} from './conversations.js';
|
||||||
|
export {
|
||||||
|
createAgentsRepo,
|
||||||
|
type AgentsRepo,
|
||||||
|
type Agent as AgentConfig,
|
||||||
|
type NewAgent as NewAgentConfig,
|
||||||
|
} from './agents.js';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clack/prompts": "^0.9.0",
|
||||||
"@mosaic/mosaic": "workspace:^",
|
"@mosaic/mosaic": "workspace:^",
|
||||||
"@mosaic/prdy": "workspace:^",
|
"@mosaic/prdy": "workspace:^",
|
||||||
"@mosaic/quality-rails": "workspace:^",
|
"@mosaic/quality-rails": "workspace:^",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { buildPrdyCli } from '@mosaic/prdy';
|
|
||||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
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();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -51,8 +53,17 @@ program
|
|||||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||||
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
||||||
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
||||||
|
.option('--agent <idOrName>', 'Connect to a specific agent')
|
||||||
|
.option('--project <idOrName>', 'Scope session to project')
|
||||||
.action(
|
.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');
|
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||||||
|
|
||||||
// Try loading saved session
|
// 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
|
// Dynamic import to avoid loading React/Ink for other commands
|
||||||
const { render } = await import('ink');
|
const { render } = await import('ink');
|
||||||
const React = await import('react');
|
const React = await import('react');
|
||||||
@@ -101,6 +156,9 @@ program
|
|||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
initialModel: opts.model,
|
initialModel: opts.model,
|
||||||
initialProvider: opts.provider,
|
initialProvider: opts.provider,
|
||||||
|
agentId,
|
||||||
|
agentName: agentName ?? undefined,
|
||||||
|
projectId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -115,23 +173,12 @@ sessionsCmd
|
|||||||
.description('List active agent sessions')
|
.description('List active agent sessions')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
.action(async (opts: { gateway: string }) => {
|
.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 { 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 {
|
try {
|
||||||
const result = await fetchSessions(opts.gateway, session.cookie);
|
const result = await fetchSessions(auth.gateway, auth.cookie);
|
||||||
if (result.total === 0) {
|
if (result.total === 0) {
|
||||||
console.log('No active sessions.');
|
console.log('No active sessions.');
|
||||||
return;
|
return;
|
||||||
@@ -193,23 +240,12 @@ sessionsCmd
|
|||||||
.description('Terminate an active agent session')
|
.description('Terminate an active agent session')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
.action(async (id: string, opts: { gateway: string }) => {
|
.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 { 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 {
|
try {
|
||||||
await deleteSession(opts.gateway, session.cookie, id);
|
await deleteSession(auth.gateway, auth.cookie, id);
|
||||||
console.log(`Session ${id} destroyed.`);
|
console.log(`Session ${id} destroyed.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
@@ -217,13 +253,17 @@ sessionsCmd
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── prdy ───────────────────────────────────────────────────────────────
|
// ─── agent ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const prdyWrapper = buildPrdyCli();
|
registerAgentCommand(program);
|
||||||
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
|
||||||
if (prdyCmd !== undefined) {
|
// ─── mission ───────────────────────────────────────────────────────────
|
||||||
program.addCommand(prdyCmd as unknown as Command);
|
|
||||||
}
|
registerMissionCommand(program);
|
||||||
|
|
||||||
|
// ─── prdy ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerPrdyCommand(program);
|
||||||
|
|
||||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
241
packages/cli/src/commands/agent.ts
Normal file
241
packages/cli/src/commands/agent.ts
Normal file
@@ -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 <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
|
.option('--list', 'List all agents')
|
||||||
|
.option('--new', 'Create a new agent')
|
||||||
|
.option('--show <idOrName>', 'Show agent details')
|
||||||
|
.option('--update <idOrName>', 'Update an agent')
|
||||||
|
.option('--delete <idOrName>', '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<AgentConfigInfo | undefined> {
|
||||||
|
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<string> => 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<string> => 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<string, unknown> = {};
|
||||||
|
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<string>((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.`);
|
||||||
|
}
|
||||||
385
packages/cli/src/commands/mission.ts
Normal file
385
packages/cli/src/commands/mission.ts
Normal file
@@ -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 <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
|
.option('--list', 'List all missions')
|
||||||
|
.option('--init', 'Create a new mission')
|
||||||
|
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
||||||
|
.option('--update <idOrName>', 'Update a mission')
|
||||||
|
.option('--project <idOrName>', '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 <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
|
.option('--list', 'List tasks for a mission')
|
||||||
|
.option('--new', 'Create a task')
|
||||||
|
.option('--update <taskId>', 'Update a task')
|
||||||
|
.option('--mission <idOrName>', '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<MissionInfo | undefined> {
|
||||||
|
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<string | undefined> {
|
||||||
|
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<string> => 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<string> => 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<string, unknown> = {};
|
||||||
|
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<string> => 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<string> => 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<string, unknown> = {};
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/cli/src/commands/prdy.ts
Normal file
55
packages/cli/src/commands/prdy.ts
Normal file
@@ -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 <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
|
.option('--init [name]', 'Create a new PRD')
|
||||||
|
.option('--update [name]', 'Update an existing PRD')
|
||||||
|
.option('--project <idOrName>', '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;
|
||||||
|
}
|
||||||
58
packages/cli/src/commands/select-dialog.ts
Normal file
58
packages/cli/src/commands/select-dialog.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
|
||||||
|
*/
|
||||||
|
export async function selectItem<T>(
|
||||||
|
items: T[],
|
||||||
|
opts: {
|
||||||
|
message: string;
|
||||||
|
render: (item: T) => string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
},
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
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<string>((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];
|
||||||
|
}
|
||||||
29
packages/cli/src/commands/with-auth.ts
Normal file
29
packages/cli/src/commands/with-auth.ts
Normal file
@@ -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<AuthContext> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export interface TuiAppProps {
|
|||||||
sessionCookie?: string;
|
sessionCookie?: string;
|
||||||
initialModel?: string;
|
initialModel?: string;
|
||||||
initialProvider?: string;
|
initialProvider?: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
@@ -27,6 +30,9 @@ export function TuiApp({
|
|||||||
sessionCookie,
|
sessionCookie,
|
||||||
initialModel,
|
initialModel,
|
||||||
initialProvider,
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
projectId: _projectId,
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const gitInfo = useGitInfo();
|
const gitInfo = useGitInfo();
|
||||||
@@ -38,6 +44,7 @@ export function TuiApp({
|
|||||||
initialConversationId: conversationId,
|
initialConversationId: conversationId,
|
||||||
initialModel,
|
initialModel,
|
||||||
initialProvider,
|
initialProvider,
|
||||||
|
agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||||
@@ -211,7 +218,7 @@ export function TuiApp({
|
|||||||
modelName={socket.modelName}
|
modelName={socket.modelName}
|
||||||
thinkingLevel={socket.thinkingLevel}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
contextWindow={socket.tokenUsage.contextWindow}
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
agentName="default"
|
agentName={agentName ?? 'default'}
|
||||||
connected={socket.connected}
|
connected={socket.connected}
|
||||||
connecting={socket.connecting}
|
connecting={socket.connecting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
export interface ModelInfo {
|
||||||
@@ -30,10 +30,88 @@ export interface SessionListResult {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Agent Config types ──
|
||||||
* Fetch the list of available models from the gateway.
|
|
||||||
* Returns an empty array on network or auth errors so the TUI can still function.
|
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<string, unknown> | 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<string, unknown>[] | null;
|
||||||
|
config: Record<string, unknown> | 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<T>(res: Response, errorPrefix: string): Promise<T> {
|
||||||
|
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(
|
export async function fetchAvailableModels(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie?: 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(
|
export async function fetchProviders(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie?: string,
|
sessionCookie?: string,
|
||||||
@@ -76,28 +150,18 @@ export async function fetchProviders(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Session endpoints ──
|
||||||
* Fetch the list of active agent sessions from the gateway.
|
|
||||||
* Throws on network or auth errors.
|
|
||||||
*/
|
|
||||||
export async function fetchSessions(
|
export async function fetchSessions(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie: string,
|
sessionCookie: string,
|
||||||
): Promise<SessionListResult> {
|
): Promise<SessionListResult> {
|
||||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||||
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as SessionListResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy (terminate) an agent session on the gateway.
|
|
||||||
* Throws on network or auth errors.
|
|
||||||
*/
|
|
||||||
export async function deleteSession(
|
export async function deleteSession(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
sessionCookie: string,
|
sessionCookie: string,
|
||||||
@@ -105,10 +169,220 @@ export async function deleteSession(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
const body = await res.text().catch(() => '');
|
const body = await res.text().catch(() => '');
|
||||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent Config endpoints ──
|
||||||
|
|
||||||
|
export async function fetchAgentConfigs(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<AgentConfigInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<AgentConfigInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo>(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<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<AgentConfigInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<AgentConfigInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<ProjectInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/projects`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mission endpoints ──
|
||||||
|
|
||||||
|
export async function fetchMissions(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<MissionInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<MissionInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo>(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<string, unknown>[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<MissionInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo>(res, 'Failed to create mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<MissionInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo>(res, 'Failed to update mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<MissionTaskInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionTaskInfo[]>(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<MissionTaskInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMissionTask(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
missionId: string,
|
||||||
|
taskId: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<MissionTaskInfo> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface UseSocketOptions {
|
|||||||
initialConversationId?: string;
|
initialConversationId?: string;
|
||||||
initialModel?: string;
|
initialModel?: string;
|
||||||
initialProvider?: string;
|
initialProvider?: string;
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSocketReturn {
|
export interface UseSocketReturn {
|
||||||
@@ -80,7 +81,14 @@ const EMPTY_USAGE: TokenUsage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
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 [connected, setConnected] = useState(false);
|
||||||
const [connecting, setConnecting] = useState(true);
|
const [connecting, setConnecting] = useState(true);
|
||||||
@@ -231,6 +239,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
|||||||
content,
|
content,
|
||||||
...(initialProvider ? { provider: initialProvider } : {}),
|
...(initialProvider ? { provider: initialProvider } : {}),
|
||||||
...(initialModel ? { modelId: initialModel } : {}),
|
...(initialModel ? { modelId: initialModel } : {}),
|
||||||
|
...(agentId ? { agentId } : {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[conversationId, isStreaming],
|
[conversationId, isStreaming],
|
||||||
|
|||||||
@@ -190,18 +190,32 @@ export const events = pgTable(
|
|||||||
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
|
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const agents = pgTable('agents', {
|
export const agents = pgTable(
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
'agents',
|
||||||
name: text('name').notNull(),
|
{
|
||||||
provider: text('provider').notNull(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
model: text('model').notNull(),
|
name: text('name').notNull(),
|
||||||
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
provider: text('provider').notNull(),
|
||||||
.notNull()
|
model: text('model').notNull(),
|
||||||
.default('idle'),
|
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
||||||
config: jsonb('config'),
|
.notNull()
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
.default('idle'),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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<string[]>(),
|
||||||
|
skills: jsonb('skills').$type<string[]>(),
|
||||||
|
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(
|
export const tickets = pgTable(
|
||||||
'tickets',
|
'tickets',
|
||||||
@@ -243,6 +257,7 @@ export const conversations = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
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),
|
archived: boolean('archived').notNull().default(false),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -250,6 +265,7 @@ export const conversations = pgTable(
|
|||||||
(t) => [
|
(t) => [
|
||||||
index('conversations_user_id_idx').on(t.userId),
|
index('conversations_user_id_idx').on(t.userId),
|
||||||
index('conversations_project_id_idx').on(t.projectId),
|
index('conversations_project_id_idx').on(t.projectId),
|
||||||
|
index('conversations_agent_id_idx').on(t.agentId),
|
||||||
index('conversations_archived_idx').on(t.archived),
|
index('conversations_archived_idx').on(t.archived),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface ChatMessagePayload {
|
|||||||
content: string;
|
content: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Session info pushed when session is created or model changes */
|
/** Session info pushed when session is created or model changes */
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -286,6 +286,9 @@ importers:
|
|||||||
|
|
||||||
packages/cli:
|
packages/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@clack/prompts':
|
||||||
|
specifier: ^0.9.0
|
||||||
|
version: 0.9.1
|
||||||
'@mosaic/mosaic':
|
'@mosaic/mosaic':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../mosaic
|
version: link:../mosaic
|
||||||
|
|||||||
Reference in New Issue
Block a user