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 { ConversationsController } from '../conversations/conversations.controller.js';
|
||||
import { MissionsController } from '../missions/missions.controller.js';
|
||||
@@ -25,12 +25,21 @@ function createBrain() {
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(),
|
||||
findAllByUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByIdAndUser: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
missionTasks: {
|
||||
findByMissionAndUser: vi.fn(),
|
||||
findByIdAndUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
@@ -65,14 +74,14 @@ describe('Resource ownership checks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a mission owned by another project owner', async () => {
|
||||
it('forbids access to a mission owned by another user', async () => {
|
||||
const brain = createBrain();
|
||||
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
// findByIdAndUser returns undefined when the mission doesn't belong to the user
|
||||
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
|
||||
const controller = new MissionsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
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 { ProvidersController } from './providers.controller.js';
|
||||
import { SessionsController } from './sessions.controller.js';
|
||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
@@ -13,7 +14,7 @@ import { SkillsModule } from '../skills/skills.module.js';
|
||||
@Module({
|
||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||
controllers: [ProvidersController, SessionsController],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
|
||||
@@ -49,6 +49,12 @@ export interface AgentSessionOptions {
|
||||
allowedTools?: string[];
|
||||
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
||||
isAdmin?: boolean;
|
||||
/**
|
||||
* DB agent config ID. When provided, loads agent config from DB and merges
|
||||
* provider, model, systemPrompt, and allowedTools. Explicit call-site options
|
||||
* take precedence over config values.
|
||||
*/
|
||||
agentConfigId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
@@ -146,16 +152,39 @@ export class AgentService implements OnModuleDestroy {
|
||||
sessionId: string,
|
||||
options?: AgentSessionOptions,
|
||||
): Promise<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 modelId = model?.id ?? 'default';
|
||||
|
||||
// Resolve sandbox directory: option > env var > process.cwd()
|
||||
const sandboxDir =
|
||||
options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
||||
mergedOptions?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
||||
|
||||
// Resolve allowed tool set
|
||||
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
|
||||
const allowedTools = this.resolveAllowedTools(
|
||||
mergedOptions?.isAdmin ?? false,
|
||||
mergedOptions?.allowedTools,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
||||
@@ -194,7 +223,8 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
// Build system prompt: platform prompt + skill additions appended
|
||||
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
||||
const platformPrompt =
|
||||
mergedOptions?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
||||
const appendSystemPrompt =
|
||||
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
||||
|
||||
|
||||
@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
modelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
provider: data.provider,
|
||||
modelId: data.modelId,
|
||||
agentConfigId: data.agentId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import type {
|
||||
CreateDbMissionDto,
|
||||
UpdateDbMissionDto,
|
||||
CreateMissionTaskDto,
|
||||
UpdateMissionTaskDto,
|
||||
} from './coord.dto.js';
|
||||
|
||||
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||
function findMonorepoRoot(start: string): string {
|
||||
@@ -57,13 +44,15 @@ function resolveAndValidatePath(raw: string | undefined): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based coord endpoints for agent tool consumption.
|
||||
* DB-backed mission CRUD has moved to MissionsController at /api/missions.
|
||||
*/
|
||||
@Controller('api/coord')
|
||||
@UseGuards(AuthGuard)
|
||||
export class CoordController {
|
||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||
|
||||
// ── File-based coord endpoints (legacy) ──
|
||||
|
||||
@Get('status')
|
||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||
@@ -85,121 +74,4 @@ export class CoordController {
|
||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ── DB-backed mission endpoints ──
|
||||
|
||||
@Get('missions')
|
||||
async listDbMissions(@CurrentUser() user: { id: string }) {
|
||||
return this.coordService.getMissionsByUser(user.id);
|
||||
}
|
||||
|
||||
@Get('missions/:id')
|
||||
async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(id, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Post('missions')
|
||||
async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) {
|
||||
return this.coordService.createDbMission({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: dto.projectId,
|
||||
userId: user.id,
|
||||
phase: dto.phase,
|
||||
milestones: dto.milestones,
|
||||
config: dto.config,
|
||||
status: dto.status,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('missions/:id')
|
||||
async updateDbMission(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDbMissionDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.updateDbMission(id, user.id, dto);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Delete('missions/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const deleted = await this.coordService.deleteDbMission(id, user.id);
|
||||
if (!deleted) throw new NotFoundException('Mission not found');
|
||||
}
|
||||
|
||||
// ── DB-backed mission task endpoints ──
|
||||
|
||||
@Get('missions/:missionId/mission-tasks')
|
||||
async listMissionTasks(
|
||||
@Param('missionId') missionId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id);
|
||||
}
|
||||
|
||||
@Get('missions/:missionId/mission-tasks/:taskId')
|
||||
async getMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id);
|
||||
if (!task) throw new NotFoundException('Mission task not found');
|
||||
return task;
|
||||
}
|
||||
|
||||
@Post('missions/:missionId/mission-tasks')
|
||||
async createMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Body() dto: CreateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.coordService.createMissionTask({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('missions/:missionId/mission-tasks/:taskId')
|
||||
async updateMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const updated = await this.coordService.updateMissionTask(taskId, user.id, dto);
|
||||
if (!updated) throw new NotFoundException('Mission task not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete('missions/:missionId/mission-tasks/:taskId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const deleted = await this.coordService.deleteMissionTask(taskId, user.id);
|
||||
if (!deleted) throw new NotFoundException('Mission task not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
loadMission,
|
||||
getMissionStatus,
|
||||
@@ -14,12 +12,14 @@ import {
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* File-based coord operations for agent tool consumption.
|
||||
* DB-backed mission CRUD is handled directly by MissionsController via Brain repos.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CoordService {
|
||||
private readonly logger = new Logger(CoordService.name);
|
||||
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||
try {
|
||||
return await loadMission(projectPath);
|
||||
@@ -74,68 +74,4 @@ export class CoordService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB-backed methods for multi-tenant mission management ──
|
||||
|
||||
async getMissionsByUser(userId: string) {
|
||||
return this.brain.missions.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getMissionByIdAndUser(id: string, userId: string) {
|
||||
return this.brain.missions.findByIdAndUser(id, userId);
|
||||
}
|
||||
|
||||
async getMissionsByProjectAndUser(projectId: string, userId: string) {
|
||||
return this.brain.missions.findByProjectAndUser(projectId, userId);
|
||||
}
|
||||
|
||||
async createDbMission(data: Parameters<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,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -17,33 +16,42 @@ import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
||||
import {
|
||||
CreateMissionDto,
|
||||
UpdateMissionDto,
|
||||
CreateMissionTaskDto,
|
||||
UpdateMissionTaskDto,
|
||||
} from './missions.dto.js';
|
||||
|
||||
@Controller('api/missions')
|
||||
@UseGuards(AuthGuard)
|
||||
export class MissionsController {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
// ── Missions CRUD (user-scoped) ──
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.brain.missions.findAll();
|
||||
async list(@CurrentUser() user: { id: string }) {
|
||||
return this.brain.missions.findAllByUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedMission(id, user.id);
|
||||
const mission = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
||||
}
|
||||
return this.brain.missions.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: dto.projectId,
|
||||
userId: user.id,
|
||||
phase: dto.phase,
|
||||
milestones: dto.milestones,
|
||||
config: dto.config,
|
||||
status: dto.status,
|
||||
});
|
||||
}
|
||||
@@ -54,10 +62,8 @@ export class MissionsController {
|
||||
@Body() dto: UpdateMissionDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedMission(id, user.id);
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
||||
}
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission not found');
|
||||
const mission = await this.brain.missions.update(id, dto);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
@@ -66,33 +72,81 @@ export class MissionsController {
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedMission(id, user.id);
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission not found');
|
||||
const deleted = await this.brain.missions.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Mission not found');
|
||||
}
|
||||
|
||||
private async getOwnedMission(id: string, userId: string) {
|
||||
const mission = await this.brain.missions.findById(id);
|
||||
// ── Mission Tasks sub-routes ──
|
||||
|
||||
@Get(':missionId/tasks')
|
||||
async listTasks(@Param('missionId') missionId: string, @CurrentUser() user: { id: string }) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
await this.getOwnedProject(mission.projectId, userId, 'Mission');
|
||||
return mission;
|
||||
return this.brain.missionTasks.findByMissionAndUser(missionId, user.id);
|
||||
}
|
||||
|
||||
private async getOwnedProject(
|
||||
projectId: string | null | undefined,
|
||||
userId: string,
|
||||
resourceName: string,
|
||||
@Get(':missionId/tasks/:taskId')
|
||||
async getTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
if (!projectId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const task = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
|
||||
if (!task) throw new NotFoundException('Mission task not found');
|
||||
return task;
|
||||
}
|
||||
|
||||
const project = await this.brain.projects.findById(projectId);
|
||||
if (!project) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
@Post(':missionId/tasks')
|
||||
async createTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Body() dto: CreateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.brain.missionTasks.create({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
});
|
||||
}
|
||||
|
||||
assertOwner(project.ownerId, userId, resourceName);
|
||||
return project;
|
||||
@Patch(':missionId/tasks/:taskId')
|
||||
async updateTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission task not found');
|
||||
const updated = await this.brain.missionTasks.update(taskId, dto);
|
||||
if (!updated) throw new NotFoundException('Mission task not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete(':missionId/tasks/:taskId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(taskId, user.id);
|
||||
if (!existing) throw new NotFoundException('Mission task not found');
|
||||
const deleted = await this.brain.missionTasks.remove(taskId);
|
||||
if (!deleted) throw new NotFoundException('Mission task not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { IsArray, IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
|
||||
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||
|
||||
export class CreateMissionDto {
|
||||
@IsString()
|
||||
@@ -19,6 +20,19 @@ export class CreateMissionDto {
|
||||
@IsOptional()
|
||||
@IsIn(missionStatuses)
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
phase?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
milestones?: Record<string, unknown>[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateMissionDto {
|
||||
@@ -40,7 +54,70 @@ export class UpdateMissionDto {
|
||||
@IsIn(missionStatuses)
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
phase?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
milestones?: Record<string, unknown>[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
|
||||
| id | status | milestone | description | pr | notes |
|
||||
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------- |
|
||||
| ------ | ----------- | --------- | --------------------------------------------------------------------- | ---- | ------------- |
|
||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
||||
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
||||
@@ -74,6 +74,8 @@
|
||||
| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done |
|
||||
| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done |
|
||||
| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done |
|
||||
| P8-005 | in-progress | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | — | |
|
||||
| P8-006 | not-started | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | — | |
|
||||
| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
||||
|
||||
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 { createTasksRepo, type TasksRepo } from './tasks.js';
|
||||
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
|
||||
import { createAgentsRepo, type AgentsRepo } from './agents.js';
|
||||
|
||||
export interface Brain {
|
||||
projects: ProjectsRepo;
|
||||
@@ -11,6 +12,7 @@ export interface Brain {
|
||||
missionTasks: MissionTasksRepo;
|
||||
tasks: TasksRepo;
|
||||
conversations: ConversationsRepo;
|
||||
agents: AgentsRepo;
|
||||
}
|
||||
|
||||
export function createBrain(db: Db): Brain {
|
||||
@@ -20,5 +22,6 @@ export function createBrain(db: Db): Brain {
|
||||
missionTasks: createMissionTasksRepo(db),
|
||||
tasks: createTasksRepo(db),
|
||||
conversations: createConversationsRepo(db),
|
||||
agents: createAgentsRepo(db),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,3 +26,9 @@ export {
|
||||
type Message,
|
||||
type NewMessage,
|
||||
} from './conversations.js';
|
||||
export {
|
||||
createAgentsRepo,
|
||||
type AgentsRepo,
|
||||
type Agent as AgentConfig,
|
||||
type NewAgent as NewAgentConfig,
|
||||
} from './agents.js';
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"@mosaic/mosaic": "workspace:^",
|
||||
"@mosaic/prdy": "workspace:^",
|
||||
"@mosaic/quality-rails": "workspace:^",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { buildPrdyCli } from '@mosaic/prdy';
|
||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerPrdyCommand } from './commands/prdy.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -51,8 +53,17 @@ program
|
||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||
.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('--agent <idOrName>', 'Connect to a specific agent')
|
||||
.option('--project <idOrName>', 'Scope session to project')
|
||||
.action(
|
||||
async (opts: { gateway: string; conversation?: string; model?: string; provider?: string }) => {
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
conversation?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
agent?: string;
|
||||
project?: string;
|
||||
}) => {
|
||||
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||||
|
||||
// Try loading saved session
|
||||
@@ -89,6 +100,50 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve agent ID if --agent was passed by name
|
||||
let agentId: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
if (opts.agent) {
|
||||
try {
|
||||
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
|
||||
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
|
||||
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
|
||||
if (match) {
|
||||
agentId = match.id;
|
||||
agentName = match.name;
|
||||
} else {
|
||||
console.error(`Agent "${opts.agent}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve project ID if --project was passed by name
|
||||
let projectId: string | undefined;
|
||||
if (opts.project) {
|
||||
try {
|
||||
const { fetchProjects } = await import('./tui/gateway-api.js');
|
||||
const projects = await fetchProjects(opts.gateway, session.cookie);
|
||||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||||
if (match) {
|
||||
projectId = match.id;
|
||||
} else {
|
||||
console.error(`Project "${opts.project}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic import to avoid loading React/Ink for other commands
|
||||
const { render } = await import('ink');
|
||||
const React = await import('react');
|
||||
@@ -101,6 +156,9 @@ program
|
||||
sessionCookie: session.cookie,
|
||||
initialModel: opts.model,
|
||||
initialProvider: opts.provider,
|
||||
agentId,
|
||||
agentName: agentName ?? undefined,
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -115,23 +173,12 @@ sessionsCmd
|
||||
.description('List active agent sessions')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||
.action(async (opts: { gateway: string }) => {
|
||||
const { loadSession, validateSession } = await import('./auth.js');
|
||||
const { withAuth } = await import('./commands/with-auth.js');
|
||||
const auth = await withAuth(opts.gateway);
|
||||
const { fetchSessions } = await import('./tui/gateway-api.js');
|
||||
|
||||
const session = loadSession(opts.gateway);
|
||||
if (!session) {
|
||||
console.error('Not signed in. Run `mosaic login` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valid = await validateSession(opts.gateway, session.cookie);
|
||||
if (!valid) {
|
||||
console.error('Session expired. Run `mosaic login` again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchSessions(opts.gateway, session.cookie);
|
||||
const result = await fetchSessions(auth.gateway, auth.cookie);
|
||||
if (result.total === 0) {
|
||||
console.log('No active sessions.');
|
||||
return;
|
||||
@@ -193,23 +240,12 @@ sessionsCmd
|
||||
.description('Terminate an active agent session')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||
.action(async (id: string, opts: { gateway: string }) => {
|
||||
const { loadSession, validateSession } = await import('./auth.js');
|
||||
const { withAuth } = await import('./commands/with-auth.js');
|
||||
const auth = await withAuth(opts.gateway);
|
||||
const { deleteSession } = await import('./tui/gateway-api.js');
|
||||
|
||||
const session = loadSession(opts.gateway);
|
||||
if (!session) {
|
||||
console.error('Not signed in. Run `mosaic login` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valid = await validateSession(opts.gateway, session.cookie);
|
||||
if (!valid) {
|
||||
console.error('Session expired. Run `mosaic login` again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSession(opts.gateway, session.cookie, id);
|
||||
await deleteSession(auth.gateway, auth.cookie, id);
|
||||
console.log(`Session ${id} destroyed.`);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -217,13 +253,17 @@ sessionsCmd
|
||||
}
|
||||
});
|
||||
|
||||
// ─── prdy ───────────────────────────────────────────────────────────────
|
||||
// ─── agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
const prdyWrapper = buildPrdyCli();
|
||||
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
||||
if (prdyCmd !== undefined) {
|
||||
program.addCommand(prdyCmd as unknown as Command);
|
||||
}
|
||||
registerAgentCommand(program);
|
||||
|
||||
// ─── mission ───────────────────────────────────────────────────────────
|
||||
|
||||
registerMissionCommand(program);
|
||||
|
||||
// ─── prdy ──────────────────────────────────────────────────────────────
|
||||
|
||||
registerPrdyCommand(program);
|
||||
|
||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function TuiApp({
|
||||
@@ -27,6 +30,9 @@ export function TuiApp({
|
||||
sessionCookie,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
agentName,
|
||||
projectId: _projectId,
|
||||
}: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const gitInfo = useGitInfo();
|
||||
@@ -38,6 +44,7 @@ export function TuiApp({
|
||||
initialConversationId: conversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||
@@ -211,7 +218,7 @@ export function TuiApp({
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName="default"
|
||||
agentName={agentName ?? 'default'}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Minimal gateway REST API client for the TUI.
|
||||
* Minimal gateway REST API client for the TUI and CLI commands.
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
@@ -30,10 +30,88 @@ export interface SessionListResult {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of available models from the gateway.
|
||||
* Returns an empty array on network or auth errors so the TUI can still function.
|
||||
*/
|
||||
// ── Agent Config types ──
|
||||
|
||||
export interface AgentConfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
ownerId: string | null;
|
||||
systemPrompt: string | null;
|
||||
allowedTools: string[] | null;
|
||||
skills: string[] | null;
|
||||
isSystem: boolean;
|
||||
config: Record<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(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
@@ -53,10 +131,6 @@ export async function fetchAvailableModels(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of providers (with their models) from the gateway.
|
||||
* Returns an empty array on network or auth errors.
|
||||
*/
|
||||
export async function fetchProviders(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
@@ -76,28 +150,18 @@ export async function fetchProviders(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of active agent sessions from the gateway.
|
||||
* Throws on network or auth errors.
|
||||
*/
|
||||
// ── Session endpoints ──
|
||||
|
||||
export async function fetchSessions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<SessionListResult> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
|
||||
}
|
||||
return (await res.json()) as SessionListResult;
|
||||
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy (terminate) an agent session on the gateway.
|
||||
* Throws on network or auth errors.
|
||||
*/
|
||||
export async function deleteSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
@@ -105,10 +169,220 @@ export async function deleteSession(
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: sessionCookie, Origin: gatewayUrl },
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Config endpoints ──
|
||||
|
||||
export async function fetchAgentConfigs(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<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;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface UseSocketReturn {
|
||||
@@ -80,7 +81,14 @@ const EMPTY_USAGE: TokenUsage = {
|
||||
};
|
||||
|
||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
const { gatewayUrl, sessionCookie, initialConversationId, initialModel, initialProvider } = opts;
|
||||
const {
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
} = opts;
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(true);
|
||||
@@ -231,6 +239,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
content,
|
||||
...(initialProvider ? { provider: initialProvider } : {}),
|
||||
...(initialModel ? { modelId: initialModel } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming],
|
||||
|
||||
@@ -190,7 +190,9 @@ export const events = pgTable(
|
||||
(t) => [index('events_type_idx').on(t.type), index('events_date_idx').on(t.date)],
|
||||
);
|
||||
|
||||
export const agents = pgTable('agents', {
|
||||
export const agents = pgTable(
|
||||
'agents',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
@@ -198,10 +200,22 @@ export const agents = pgTable('agents', {
|
||||
status: text('status', { enum: ['idle', 'active', 'error', 'offline'] })
|
||||
.notNull()
|
||||
.default('idle'),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||
ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
systemPrompt: text('system_prompt'),
|
||||
allowedTools: jsonb('allowed_tools').$type<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(
|
||||
'tickets',
|
||||
@@ -243,6 +257,7 @@ export const conversations = pgTable(
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||
agentId: uuid('agent_id').references(() => agents.id, { onDelete: 'set null' }),
|
||||
archived: boolean('archived').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -250,6 +265,7 @@ export const conversations = pgTable(
|
||||
(t) => [
|
||||
index('conversations_user_id_idx').on(t.userId),
|
||||
index('conversations_project_id_idx').on(t.projectId),
|
||||
index('conversations_agent_id_idx').on(t.agentId),
|
||||
index('conversations_archived_idx').on(t.archived),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ChatMessagePayload {
|
||||
content: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/** Session info pushed when session is created or model changes */
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -286,6 +286,9 @@ importers:
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.1
|
||||
'@mosaic/mosaic':
|
||||
specifier: workspace:^
|
||||
version: link:../mosaic
|
||||
|
||||
Reference in New Issue
Block a user