Compare commits
36 Commits
v0.0.8
...
70d7ea3be4
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d7ea3be4 | |||
| c54ee4c8d1 | |||
| ccdad96cbe | |||
| cbfd6fb996 | |||
| 3f8553ce07 | |||
| bf668e18f1 | |||
| 1f2b8125c6 | |||
| 93645295d5 | |||
| 7a52652be6 | |||
| 791c8f505e | |||
| 12653477d6 | |||
| dedfa0d9ac | |||
| c1d3dfd77e | |||
| f0476cae92 | |||
| b6effdcd6b | |||
| 39ef2ff123 | |||
| a989b5e549 | |||
| ff27e944a1 | |||
| 0821393c1d | |||
| 24f5c0699a | |||
| 96409c40bf | |||
| 8628f4f93a | |||
| b649b5c987 | |||
| b4d03a8b49 | |||
| 85aeebbde2 | |||
| a4bb563779 | |||
| 7f6464bbda | |||
| f0741e045f | |||
| 5a1991924c | |||
| bd5d14d07f | |||
| d5a1791dc5 | |||
| bd81c12071 | |||
| 4da255bf04 | |||
| 82c10a7b33 | |||
| d31070177c | |||
| 3792576566 |
19
.env.example
19
.env.example
@@ -123,7 +123,24 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
|||||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||||
|
|
||||||
|
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||||
# AUTHENTIK_CLIENT_ID=
|
# AUTHENTIK_CLIENT_ID=
|
||||||
# AUTHENTIK_CLIENT_SECRET=
|
# AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||||
|
# WORKOS_CLIENT_ID=client_...
|
||||||
|
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||||
|
# WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback/workos
|
||||||
|
|
||||||
|
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||||
|
# KEYCLOAK_URL=https://auth.example.com
|
||||||
|
# KEYCLOAK_REALM=master
|
||||||
|
# KEYCLOAK_CLIENT_ID=mosaic
|
||||||
|
# KEYCLOAK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
||||||
|
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||||
|
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ variables:
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
- event: [push, pull_request, manual]
|
||||||
|
|
||||||
# Turbo remote cache is at turbo.mosaicstack.dev (ducktors/turborepo-remote-cache).
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# TURBO_TOKEN is a Woodpecker secret injected via from_secret into the environment.
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
# Turbo picks up TURBO_API, TURBO_TOKEN, and TURBO_TEAM automatically.
|
# This avoids from_secret which is blocked on pull_request events.
|
||||||
|
# If the env vars aren't set, turbo falls back to local cache only.
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
install:
|
install:
|
||||||
@@ -18,11 +19,6 @@ steps:
|
|||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
@@ -32,11 +28,6 @@ steps:
|
|||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm lint
|
- pnpm lint
|
||||||
@@ -53,11 +44,6 @@ steps:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm test
|
- pnpm test
|
||||||
@@ -66,11 +52,6 @@ steps:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm build
|
- pnpm build
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -18,6 +18,7 @@ function createBrain() {
|
|||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findAllForUser: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -25,12 +26,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(),
|
||||||
@@ -58,21 +68,22 @@ describe('Resource ownership checks', () => {
|
|||||||
it('forbids access to another user project', async () => {
|
it('forbids access to another user project', async () => {
|
||||||
const brain = createBrain();
|
const brain = createBrain();
|
||||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||||
const controller = new ProjectsController(brain as never);
|
const teamsService = { canAccessProject: vi.fn().mockResolvedValue(false) };
|
||||||
|
const controller = new ProjectsController(brain as never, teamsService as never);
|
||||||
|
|
||||||
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaic/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import type { Db } from '@mosaic/db';
|
||||||
|
import { eq, users as usersTable } from '@mosaic/db';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
interface UserWithRole {
|
interface UserWithRole {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,7 +21,10 @@ interface UserWithRole {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminGuard implements CanActivate {
|
export class AdminGuard implements CanActivate {
|
||||||
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
constructor(
|
||||||
|
@Inject(AUTH) private readonly auth: Auth,
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
@@ -32,7 +38,21 @@ export class AdminGuard implements CanActivate {
|
|||||||
|
|
||||||
const user = result.user as UserWithRole;
|
const user = result.user as UserWithRole;
|
||||||
|
|
||||||
if (user.role !== 'admin') {
|
// Ensure the role field is populated. better-auth should include additionalFields
|
||||||
|
// in the session, but as a fallback, fetch the role from the database if needed.
|
||||||
|
let userRole = user.role;
|
||||||
|
if (!userRole) {
|
||||||
|
const [dbUser] = await this.db
|
||||||
|
.select({ role: usersTable.role })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, user.id))
|
||||||
|
.limit(1);
|
||||||
|
userRole = dbUser?.role ?? 'member';
|
||||||
|
// Update the session user object with the fetched role
|
||||||
|
(user as UserWithRole).role = userRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRole !== 'admin') {
|
||||||
throw new ForbiddenException('Admin access required');
|
throw new ForbiddenException('Admin access required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,15 +5,17 @@ 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';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
||||||
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 {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -24,6 +24,9 @@ import { createGitTools } from './tools/git-tools.js';
|
|||||||
import { createShellTools } from './tools/shell-tools.js';
|
import { createShellTools } from './tools/shell-tools.js';
|
||||||
import { createWebTools } from './tools/web-tools.js';
|
import { createWebTools } from './tools/web-tools.js';
|
||||||
import type { SessionInfoDto } from './session.dto.js';
|
import type { SessionInfoDto } from './session.dto.js';
|
||||||
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -49,6 +52,14 @@ 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;
|
||||||
|
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSession {
|
export interface AgentSession {
|
||||||
@@ -67,6 +78,8 @@ export interface AgentSession {
|
|||||||
sandboxDir: string;
|
sandboxDir: string;
|
||||||
/** Tool names available in this session, or null when all tools are available. */
|
/** Tool names available in this session, or null when all tools are available. */
|
||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -83,6 +96,13 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(SystemOverrideService)
|
||||||
|
private readonly systemOverride: SystemOverrideService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(PreferencesService)
|
||||||
|
private readonly preferencesService: PreferencesService | null,
|
||||||
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,16 +166,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 +237,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;
|
||||||
|
|
||||||
@@ -255,6 +299,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
skillPromptAdditions: promptAdditions,
|
skillPromptAdditions: promptAdditions,
|
||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
userId: mergedOptions?.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
@@ -338,8 +383,20 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
|
let effectiveMessage = message;
|
||||||
|
if (this.systemOverride) {
|
||||||
|
const override = await this.systemOverride.get(sessionId);
|
||||||
|
if (override) {
|
||||||
|
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||||
|
await this.systemOverride.renew(sessionId);
|
||||||
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.piSession.prompt(message);
|
await session.piSession.prompt(effectiveMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||||
@@ -375,6 +432,14 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
session.listeners.clear();
|
session.listeners.clear();
|
||||||
session.channels.clear();
|
session.channels.clear();
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
|
||||||
|
// Run GC cleanup for this session (fire and forget, errors are logged)
|
||||||
|
this.gc.collect(sessionId).catch((err: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`GC collect failed for session ${sessionId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { resolve, relative, join } from 'node:path';
|
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Safety constraint: all file operations are restricted to a base directory.
|
|
||||||
* Paths that escape the sandbox via ../ traversal are rejected.
|
|
||||||
*/
|
|
||||||
function resolveSafe(baseDir: string, inputPath: string): string {
|
|
||||||
const resolved = resolve(baseDir, inputPath);
|
|
||||||
const rel = relative(baseDir, resolved);
|
|
||||||
if (rel.startsWith('..') || resolve(resolved) !== resolve(join(baseDir, rel))) {
|
|
||||||
throw new Error(`Path escape detected: "${inputPath}" resolves outside base directory`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||||
@@ -37,8 +24,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
|||||||
const { path, encoding } = params as { path: string; encoding?: string };
|
const { path, encoding } = params as { path: string; encoding?: string };
|
||||||
let safePath: string;
|
let safePath: string;
|
||||||
try {
|
try {
|
||||||
safePath = resolveSafe(baseDir, path);
|
safePath = guardPath(path, baseDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
@@ -99,8 +92,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
|||||||
};
|
};
|
||||||
let safePath: string;
|
let safePath: string;
|
||||||
try {
|
try {
|
||||||
safePath = resolveSafe(baseDir, path);
|
safePath = guardPathUnsafe(path, baseDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
@@ -151,8 +150,14 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
|||||||
const target = path ?? '.';
|
const target = path ?? '.';
|
||||||
let safePath: string;
|
let safePath: string;
|
||||||
try {
|
try {
|
||||||
safePath = resolveSafe(baseDir, target);
|
safePath = guardPath(target, baseDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
|
|||||||
@@ -2,29 +2,13 @@ import { Type } from '@sinclair/typebox';
|
|||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import { exec } from 'node:child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { resolve, relative } from 'node:path';
|
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 15_000;
|
const GIT_TIMEOUT_MS = 15_000;
|
||||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||||
|
|
||||||
/**
|
|
||||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
|
||||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
|
||||||
* falls back to the sandbox directory itself.
|
|
||||||
*/
|
|
||||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
|
||||||
if (!requestedCwd) return sandboxDir;
|
|
||||||
const resolved = resolve(sandboxDir, requestedCwd);
|
|
||||||
const rel = relative(sandboxDir, resolved);
|
|
||||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
|
||||||
// Escape attempt — fall back to sandbox root
|
|
||||||
return sandboxDir;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runGit(
|
async function runGit(
|
||||||
args: string[],
|
args: string[],
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
@@ -74,7 +58,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { cwd } = params as { cwd?: string };
|
const { cwd } = params as { cwd?: string };
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
let safeCwd: string;
|
||||||
|
try {
|
||||||
|
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
||||||
const text = result.error
|
const text = result.error
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
? `Error: ${result.error}\n${result.stderr}`
|
||||||
@@ -107,7 +105,21 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
oneline?: boolean;
|
oneline?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
let safeCwd: string;
|
||||||
|
try {
|
||||||
|
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
const args = ['log', `--max-count=${limit ?? 20}`];
|
const args = ['log', `--max-count=${limit ?? 20}`];
|
||||||
if (oneline !== false) args.push('--oneline');
|
if (oneline !== false) args.push('--oneline');
|
||||||
const result = await runGit(args, safeCwd);
|
const result = await runGit(args, safeCwd);
|
||||||
@@ -148,12 +160,43 @@ export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
path?: string;
|
path?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
let safeCwd: string;
|
||||||
|
try {
|
||||||
|
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let safePath: string | undefined;
|
||||||
|
if (path !== undefined) {
|
||||||
|
try {
|
||||||
|
safePath = guardPathUnsafe(path, defaultCwd);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
const args = ['diff'];
|
const args = ['diff'];
|
||||||
if (staged) args.push('--cached');
|
if (staged) args.push('--cached');
|
||||||
if (ref) args.push(ref);
|
if (ref) args.push(ref);
|
||||||
args.push('--');
|
args.push('--');
|
||||||
if (path) args.push(path);
|
if (safePath !== undefined) args.push(safePath);
|
||||||
const result = await runGit(args, safeCwd);
|
const result = await runGit(args, safeCwd);
|
||||||
const text = result.error
|
const text = result.error
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
? `Error: ${result.error}\n${result.stderr}`
|
||||||
|
|||||||
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
describe('guardPathUnsafe', () => {
|
||||||
|
const sandbox = '/tmp/test-sandbox';
|
||||||
|
|
||||||
|
it('allows paths inside sandbox', () => {
|
||||||
|
const result = guardPathUnsafe('foo/bar.txt', sandbox);
|
||||||
|
expect(result).toBe(path.resolve(sandbox, 'foo/bar.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows sandbox root itself', () => {
|
||||||
|
const result = guardPathUnsafe('.', sandbox);
|
||||||
|
expect(result).toBe(path.resolve(sandbox));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path traversal with ../', () => {
|
||||||
|
expect(() => guardPathUnsafe('../escape.txt', sandbox)).toThrow(SandboxEscapeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects absolute path outside sandbox', () => {
|
||||||
|
expect(() => guardPathUnsafe('/etc/passwd', sandbox)).toThrow(SandboxEscapeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deeply nested traversal', () => {
|
||||||
|
expect(() => guardPathUnsafe('a/b/../../../../../../etc/passwd', sandbox)).toThrow(
|
||||||
|
SandboxEscapeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path that starts with sandbox name but is sibling', () => {
|
||||||
|
expect(() => guardPathUnsafe('/tmp/test-sandbox-evil/file.txt', sandbox)).toThrow(
|
||||||
|
SandboxEscapeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the resolved absolute path for nested paths', () => {
|
||||||
|
const result = guardPathUnsafe('deep/nested/file.ts', sandbox);
|
||||||
|
expect(result).toBe('/tmp/test-sandbox/deep/nested/file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SandboxEscapeError includes the user path and sandbox in message', () => {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
guardPathUnsafe('../escape.txt', sandbox);
|
||||||
|
} catch (err) {
|
||||||
|
caught = err;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(SandboxEscapeError);
|
||||||
|
const e = caught as SandboxEscapeError;
|
||||||
|
expect(e.userPath).toBe('../escape.txt');
|
||||||
|
expect(e.sandboxDir).toBe(sandbox);
|
||||||
|
expect(e.message).toContain('Path escape attempt blocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guardPath', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
it('allows an existing path inside a real temp sandbox', () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||||
|
try {
|
||||||
|
const subdir = path.join(tmpDir, 'subdir');
|
||||||
|
fs.mkdirSync(subdir);
|
||||||
|
const result = guardPath('subdir', tmpDir);
|
||||||
|
expect(result).toBe(subdir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows sandbox root itself', () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||||
|
try {
|
||||||
|
const result = guardPath('.', tmpDir);
|
||||||
|
// realpathSync resolves the tmpdir symlinks (macOS /var -> /private/var)
|
||||||
|
const realTmp = fs.realpathSync.native(tmpDir);
|
||||||
|
expect(result).toBe(realTmp);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path traversal with ../ on existing sandbox', () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||||
|
try {
|
||||||
|
expect(() => guardPath('../escape', tmpDir)).toThrow(SandboxEscapeError);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects absolute path outside sandbox', () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||||
|
try {
|
||||||
|
expect(() => guardPath('/etc/passwd', tmpDir)).toThrow(SandboxEscapeError);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/gateway/src/agent/tools/path-guard.ts
Normal file
58
apps/gateway/src/agent/tools/path-guard.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
||||||
|
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
||||||
|
*
|
||||||
|
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
||||||
|
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
||||||
|
* within the user path — so symlink escape attempts are caught too.
|
||||||
|
*
|
||||||
|
* @param userPath - The path provided by the agent (may be relative or absolute)
|
||||||
|
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
||||||
|
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
||||||
|
*/
|
||||||
|
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||||
|
const resolved = path.resolve(sandboxDir, userPath);
|
||||||
|
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
||||||
|
|
||||||
|
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
||||||
|
// For the user path, we check containment BEFORE resolving symlinks in the path
|
||||||
|
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
||||||
|
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
||||||
|
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a path without resolving symlinks in the user-provided portion.
|
||||||
|
* Use for paths that may not exist yet (creates, writes).
|
||||||
|
*
|
||||||
|
* Performs a lexical containment check only using path.resolve.
|
||||||
|
*/
|
||||||
|
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
||||||
|
const resolved = path.resolve(sandboxDir, userPath);
|
||||||
|
const sandboxAbs = path.resolve(sandboxDir);
|
||||||
|
|
||||||
|
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
||||||
|
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SandboxEscapeError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly userPath: string,
|
||||||
|
public readonly sandboxDir: string,
|
||||||
|
public readonly resolvedPath: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`,
|
||||||
|
);
|
||||||
|
this.name = 'SandboxEscapeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { resolve, relative } from 'node:path';
|
import { guardPath, SandboxEscapeError } from './path-guard.js';
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||||
@@ -68,22 +68,6 @@ function extractBaseCommand(command: string): string {
|
|||||||
return firstToken.split('/').pop() ?? firstToken;
|
return firstToken.split('/').pop() ?? firstToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
|
||||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
|
||||||
* falls back to the sandbox directory itself.
|
|
||||||
*/
|
|
||||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
|
||||||
if (!requestedCwd) return sandboxDir;
|
|
||||||
const resolved = resolve(sandboxDir, requestedCwd);
|
|
||||||
const rel = relative(sandboxDir, resolved);
|
|
||||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
|
||||||
// Escape attempt — fall back to sandbox root
|
|
||||||
return sandboxDir;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommand(
|
function runCommand(
|
||||||
command: string,
|
command: string,
|
||||||
options: { timeoutMs: number; cwd?: string },
|
options: { timeoutMs: number; cwd?: string },
|
||||||
@@ -185,7 +169,21 @@ export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
let safeCwd: string;
|
||||||
|
try {
|
||||||
|
safeCwd = guardPath(cwd ?? '.', defaultCwd);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxEscapeError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runCommand(command, {
|
const result = await runCommand(command, {
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import { SkillsModule } from './skills/skills.module.js';
|
|||||||
import { PluginModule } from './plugin/plugin.module.js';
|
import { PluginModule } from './plugin/plugin.module.js';
|
||||||
import { McpModule } from './mcp/mcp.module.js';
|
import { McpModule } from './mcp/mcp.module.js';
|
||||||
import { AdminModule } from './admin/admin.module.js';
|
import { AdminModule } from './admin/admin.module.js';
|
||||||
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
|
import { GCModule } from './gc/gc.module.js';
|
||||||
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -38,6 +43,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
PluginModule,
|
PluginModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
PreferencesModule,
|
||||||
|
CommandsModule,
|
||||||
|
GCModule,
|
||||||
|
ReloadModule,
|
||||||
|
WorkspaceModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ export class ChatSocketMessageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Auth } from '@mosaic/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import type { SetThinkingPayload, SlashCommandPayload, SystemReloadPayload } from '@mosaic/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||||
@@ -37,6 +40,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(AUTH) private readonly auth: Auth,
|
@Inject(AUTH) private readonly auth: Auth,
|
||||||
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -54,6 +59,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.data.user = session.user;
|
client.data.user = session.user;
|
||||||
client.data.session = session.session;
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
|
||||||
|
// Broadcast command manifest to the newly connected client
|
||||||
|
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket): void {
|
handleDisconnect(client: Socket): void {
|
||||||
@@ -79,9 +87,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
|
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||||
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,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -112,6 +123,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
// Track channel connection
|
// Track channel connection
|
||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||||
|
|
||||||
|
// Send session info so the client knows the model/provider
|
||||||
|
{
|
||||||
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
|
if (agentSession) {
|
||||||
|
const piSession = agentSession.piSession;
|
||||||
|
client.emit('session:info', {
|
||||||
|
conversationId,
|
||||||
|
provider: agentSession.provider,
|
||||||
|
modelId: agentSession.modelId,
|
||||||
|
thinkingLevel: piSession.thinkingLevel,
|
||||||
|
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||||
|
|
||||||
@@ -130,6 +156,58 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('set:thinking')
|
||||||
|
handleSetThinking(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: SetThinkingPayload,
|
||||||
|
): void {
|
||||||
|
const session = this.agentService.getSession(data.conversationId);
|
||||||
|
if (!session) {
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
error: 'No active session for this conversation.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validLevels = session.piSession.getAvailableThinkingLevels();
|
||||||
|
if (!validLevels.includes(data.level as never)) {
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.piSession.setThinkingLevel(data.level as never);
|
||||||
|
this.logger.log(
|
||||||
|
`Thinking level set to "${data.level}" for conversation ${data.conversationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit('session:info', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
thinkingLevel: session.piSession.thinkingLevel,
|
||||||
|
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('command:execute')
|
||||||
|
async handleCommandExecute(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: SlashCommandPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||||
|
const result = await this.commandExecutor.execute(payload, userId);
|
||||||
|
client.emit('command:result', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastReload(payload: SystemReloadPayload): void {
|
||||||
|
this.server.emit('system:reload', payload);
|
||||||
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
|
}
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -143,9 +221,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.emit('agent:start', { conversationId });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'agent_end':
|
case 'agent_end': {
|
||||||
client.emit('agent:end', { conversationId });
|
// Gather usage stats from the Pi session
|
||||||
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
|
const piSession = agentSession?.piSession;
|
||||||
|
const stats = piSession?.getSessionStats();
|
||||||
|
const contextUsage = piSession?.getContextUsage();
|
||||||
|
|
||||||
|
client.emit('agent:end', {
|
||||||
|
conversationId,
|
||||||
|
usage: stats
|
||||||
|
? {
|
||||||
|
provider: agentSession?.provider ?? 'unknown',
|
||||||
|
modelId: agentSession?.modelId ?? 'unknown',
|
||||||
|
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||||
|
tokens: stats.tokens,
|
||||||
|
cost: stats.cost,
|
||||||
|
context: {
|
||||||
|
percent: contextUsage?.percent ?? null,
|
||||||
|
window: contextUsage?.contextWindow ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
import { ChatController } from './chat.controller.js';
|
import { ChatController } from './chat.controller.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [forwardRef(() => CommandsModule)],
|
||||||
controllers: [ChatController],
|
controllers: [ChatController],
|
||||||
providers: [ChatGateway],
|
providers: [ChatGateway],
|
||||||
|
exports: [ChatGateway],
|
||||||
})
|
})
|
||||||
export class ChatModule {}
|
export class ChatModule {}
|
||||||
|
|||||||
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
import type { SlashCommandPayload } from '@mosaic/types';
|
||||||
|
|
||||||
|
// Minimal mock implementations
|
||||||
|
const mockRegistry = {
|
||||||
|
getManifest: vi.fn(() => ({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{ name: 'provider', aliases: [], scope: 'agent', execution: 'hybrid', available: true },
|
||||||
|
{ name: 'mission', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAgentService = {
|
||||||
|
getSession: vi.fn(() => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSystemOverride = {
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
renew: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSessionGC = {
|
||||||
|
sweepOrphans: vi.fn(() => ({ orphanedSessions: 0, totalCleaned: [], duration: 0 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRedis = {
|
||||||
|
set: vi.fn().mockResolvedValue('OK'),
|
||||||
|
get: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildService(): CommandExecutorService {
|
||||||
|
return new CommandExecutorService(
|
||||||
|
mockRegistry as never,
|
||||||
|
mockAgentService as never,
|
||||||
|
mockSystemOverride as never,
|
||||||
|
mockSessionGC as never,
|
||||||
|
mockRedis as never,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommandExecutorService — P8-012 commands', () => {
|
||||||
|
let service: CommandExecutorService;
|
||||||
|
const userId = 'user-123';
|
||||||
|
const conversationId = 'conv-456';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = buildService();
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider login — missing provider name
|
||||||
|
it('/provider login with no provider name returns usage error', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Usage: /provider login');
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider login anthropic — success with URL containing poll token
|
||||||
|
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
expect(result.message).toContain('anthropic');
|
||||||
|
expect(result.message).toContain('http');
|
||||||
|
// data should contain loginUrl and pollToken
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
const data = result.data as Record<string, unknown>;
|
||||||
|
expect(typeof data['loginUrl']).toBe('string');
|
||||||
|
expect(typeof data['pollToken']).toBe('string');
|
||||||
|
expect(data['loginUrl'] as string).toContain('anthropic');
|
||||||
|
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
||||||
|
// Verify Valkey was called
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
|
expect(key).toContain('mosaic:auth:poll:');
|
||||||
|
const stored = JSON.parse(value) as { status: string; provider: string; userId: string };
|
||||||
|
expect(stored.status).toBe('pending');
|
||||||
|
expect(stored.provider).toBe('anthropic');
|
||||||
|
expect(stored.userId).toBe(userId);
|
||||||
|
expect(ttl).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider with no args — returns usage
|
||||||
|
it('/provider with no args returns usage message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Usage: /provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider list
|
||||||
|
it('/provider list returns success', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider logout with no name — usage error
|
||||||
|
it('/provider logout with no name returns error', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Usage: /provider logout');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /provider unknown subcommand
|
||||||
|
it('/provider unknown subcommand returns error', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'unknown',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Unknown subcommand');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /mission status
|
||||||
|
it('/mission status returns stub message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('mission');
|
||||||
|
expect(result.message).toContain('Mission status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /mission with no args
|
||||||
|
it('/mission with no args returns status stub', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Mission status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /mission set <id>
|
||||||
|
it('/mission set <id> returns confirmation', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'mission',
|
||||||
|
args: 'set my-mission-123',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('my-mission-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /agent list
|
||||||
|
it('/agent list returns stub message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('agent');
|
||||||
|
expect(result.message).toContain('agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /agent with no args
|
||||||
|
it('/agent with no args returns usage', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Usage: /agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /agent <id> — switch
|
||||||
|
it('/agent <id> returns switch confirmation', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'agent',
|
||||||
|
args: 'my-agent-id',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('my-agent-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /prdy
|
||||||
|
it('/prdy returns PRD wizard message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('prdy');
|
||||||
|
expect(result.message).toContain('mosaic prdy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /tools
|
||||||
|
it('/tools returns tools stub message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||||
|
const result = await service.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('tools');
|
||||||
|
expect(result.message).toContain('tools');
|
||||||
|
});
|
||||||
|
});
|
||||||
373
apps/gateway/src/commands/command-executor.service.ts
Normal file
373
apps/gateway/src/commands/command-executor.service.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
|
import { ReloadService } from '../reload/reload.service.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandExecutorService {
|
||||||
|
private readonly logger = new Logger(CommandExecutorService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
||||||
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => ReloadService))
|
||||||
|
private readonly reloadService: ReloadService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
|
private readonly chatGateway: ChatGateway | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||||
|
const { command, args, conversationId } = payload;
|
||||||
|
|
||||||
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||||
|
if (!def) {
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: `Unknown command: /${command}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case 'model':
|
||||||
|
return await this.handleModel(args ?? null, conversationId);
|
||||||
|
case 'thinking':
|
||||||
|
return await this.handleThinking(args ?? null, conversationId);
|
||||||
|
case 'system':
|
||||||
|
return await this.handleSystem(args ?? null, conversationId);
|
||||||
|
case 'new':
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Start a new conversation by selecting New Conversation.',
|
||||||
|
};
|
||||||
|
case 'clear':
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Conversation display cleared.',
|
||||||
|
};
|
||||||
|
case 'compact':
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Context compaction requested.',
|
||||||
|
};
|
||||||
|
case 'retry':
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Retry last message requested.',
|
||||||
|
};
|
||||||
|
case 'gc': {
|
||||||
|
// User-scoped sweep for non-admin; system-wide for admin
|
||||||
|
const result = await this.sessionGC.sweepOrphans(userId);
|
||||||
|
return {
|
||||||
|
command: 'gc',
|
||||||
|
success: true,
|
||||||
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'agent':
|
||||||
|
return await this.handleAgent(args ?? null, conversationId);
|
||||||
|
case 'provider':
|
||||||
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
|
case 'mission':
|
||||||
|
return await this.handleMission(args ?? null, conversationId, userId);
|
||||||
|
case 'prdy':
|
||||||
|
return {
|
||||||
|
command: 'prdy',
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
case 'tools':
|
||||||
|
return await this.handleTools(conversationId, userId);
|
||||||
|
case 'reload': {
|
||||||
|
if (!this.reloadService) {
|
||||||
|
return {
|
||||||
|
command: 'reload',
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'ReloadService is not available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reloadResult = await this.reloadService.reload('command');
|
||||||
|
this.chatGateway?.broadcastReload(reloadResult);
|
||||||
|
return {
|
||||||
|
command: 'reload',
|
||||||
|
success: true,
|
||||||
|
message: reloadResult.message,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: `Command /${command} is not yet implemented.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Command /${command} failed: ${err}`);
|
||||||
|
return { command, conversationId, success: false, message: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleModel(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
command: 'model',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /model <model-name>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update agent session model if session is active
|
||||||
|
// For now, acknowledge the request — full wiring done in P8-012
|
||||||
|
const session = this.agentService.getSession(conversationId);
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
command: 'model',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: `Model switch to "${args}" requested. No active session for this conversation.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'model',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: `Model switch to "${args}" requested.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleThinking(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
const level = args?.toLowerCase();
|
||||||
|
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
|
||||||
|
return {
|
||||||
|
command: 'thinking',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /thinking <none|low|medium|high|auto>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'thinking',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: `Thinking level set to "${level}".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSystem(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args || args.trim().length === 0) {
|
||||||
|
// Clear the override when called with no args
|
||||||
|
await this.systemOverride.clear(conversationId);
|
||||||
|
return {
|
||||||
|
command: 'system',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: 'Session system prompt override cleared.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.systemOverride.set(conversationId, args.trim());
|
||||||
|
return {
|
||||||
|
command: 'system',
|
||||||
|
conversationId,
|
||||||
|
success: true,
|
||||||
|
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAgent(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
command: 'agent',
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args === 'list') {
|
||||||
|
return {
|
||||||
|
command: 'agent',
|
||||||
|
success: true,
|
||||||
|
message: 'Agent listing: use the web dashboard for full agent management.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch agent — stub for now (full implementation in P8-015)
|
||||||
|
return {
|
||||||
|
command: 'agent',
|
||||||
|
success: true,
|
||||||
|
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProvider(
|
||||||
|
args: string | null,
|
||||||
|
userId: string,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /provider list | /provider login <name> | /provider logout <name>',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceIdx = args.indexOf(' ');
|
||||||
|
const subcommand = spaceIdx >= 0 ? args.slice(0, spaceIdx) : args;
|
||||||
|
const providerName = spaceIdx >= 0 ? args.slice(spaceIdx + 1).trim() : '';
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'list':
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: 'Use the web dashboard to manage providers.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'login': {
|
||||||
|
if (!providerName) {
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: false,
|
||||||
|
message: 'Usage: /provider login <provider-name>',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pollToken = crypto.randomUUID();
|
||||||
|
const key = `mosaic:auth:poll:${pollToken}`;
|
||||||
|
// Store pending state in Valkey (TTL 5 minutes)
|
||||||
|
await this.redis.set(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
|
'EX',
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
// In production this would construct an OAuth URL
|
||||||
|
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
||||||
|
conversationId,
|
||||||
|
data: { loginUrl, pollToken, provider: providerName },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'logout': {
|
||||||
|
if (!providerName) {
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: false,
|
||||||
|
message: 'Usage: /provider logout <provider-name>',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: true,
|
||||||
|
message: `Logout from ${providerName}: use the web dashboard to revoke provider tokens.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
command: 'provider',
|
||||||
|
success: false,
|
||||||
|
message: `Unknown subcommand: ${subcommand}. Use list, login, or logout.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMission(
|
||||||
|
args: string | null,
|
||||||
|
conversationId: string,
|
||||||
|
_userId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
if (!args || args === 'status') {
|
||||||
|
// TODO: fetch active mission from DB when MissionsService is available
|
||||||
|
return {
|
||||||
|
command: 'mission',
|
||||||
|
success: true,
|
||||||
|
message: 'Mission status: use the web dashboard for full mission management.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.startsWith('set ')) {
|
||||||
|
const missionId = args.slice(4).trim();
|
||||||
|
return {
|
||||||
|
command: 'mission',
|
||||||
|
success: true,
|
||||||
|
message: `Mission set to ${missionId}. Session context updated.`,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'mission',
|
||||||
|
success: true,
|
||||||
|
message: 'Usage: /mission [status|set <id>|list|tasks]',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTools(
|
||||||
|
conversationId: string,
|
||||||
|
_userId: string,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
// TODO: fetch tool list from active agent session
|
||||||
|
return {
|
||||||
|
command: 'tools',
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
'Available tools depend on the active agent configuration. Use the web dashboard to configure tool access.',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import type { CommandDef } from '@mosaic/types';
|
||||||
|
|
||||||
|
const mockCmd: CommandDef = {
|
||||||
|
name: 'test',
|
||||||
|
description: 'Test command',
|
||||||
|
aliases: ['t'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CommandRegistryService', () => {
|
||||||
|
let service: CommandRegistryService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new CommandRegistryService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty manifest', () => {
|
||||||
|
expect(service.getManifest().commands).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a command', () => {
|
||||||
|
service.registerCommand(mockCmd);
|
||||||
|
expect(service.getManifest().commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing command by name', () => {
|
||||||
|
service.registerCommand(mockCmd);
|
||||||
|
service.registerCommand({ ...mockCmd, description: 'Updated' });
|
||||||
|
expect(service.getManifest().commands).toHaveLength(1);
|
||||||
|
expect(service.getManifest().commands[0]?.description).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onModuleInit registers core commands', () => {
|
||||||
|
service.onModuleInit();
|
||||||
|
const manifest = service.getManifest();
|
||||||
|
expect(manifest.commands.length).toBeGreaterThan(5);
|
||||||
|
expect(manifest.commands.some((c) => c.name === 'model')).toBe(true);
|
||||||
|
expect(manifest.commands.some((c) => c.name === 'help')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest includes skills array', () => {
|
||||||
|
const manifest = service.getManifest();
|
||||||
|
expect(Array.isArray(manifest.skills)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest version is 1', () => {
|
||||||
|
expect(service.getManifest().version).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
273
apps/gateway/src/commands/command-registry.service.ts
Normal file
273
apps/gateway/src/commands/command-registry.service.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandRegistryService implements OnModuleInit {
|
||||||
|
private readonly commands: CommandDef[] = [];
|
||||||
|
|
||||||
|
registerCommand(def: CommandDef): void {
|
||||||
|
const existing = this.commands.findIndex((c) => c.name === def.name);
|
||||||
|
if (existing >= 0) {
|
||||||
|
this.commands[existing] = def;
|
||||||
|
} else {
|
||||||
|
this.commands.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCommands(defs: CommandDef[]): void {
|
||||||
|
for (const def of defs) {
|
||||||
|
this.registerCommand(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getManifest(): CommandManifest {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
commands: [...this.commands],
|
||||||
|
skills: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.registerCommands([
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
description: 'Switch the active model',
|
||||||
|
aliases: ['m'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'model-name',
|
||||||
|
type: 'string',
|
||||||
|
optional: false,
|
||||||
|
description: 'Model name to switch to',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thinking',
|
||||||
|
description: 'Set thinking level (none/low/medium/high/auto)',
|
||||||
|
aliases: ['t'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'level',
|
||||||
|
type: 'enum',
|
||||||
|
optional: false,
|
||||||
|
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||||
|
description: 'Thinking level',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clear',
|
||||||
|
description: 'Clear conversation context and GC session artifacts',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'compact',
|
||||||
|
description: 'Request context compaction',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'retry',
|
||||||
|
description: 'Retry the last message',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rename',
|
||||||
|
description: 'Rename current conversation',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{ name: 'name', type: 'string', optional: false, description: 'New conversation name' },
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'rest',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'history',
|
||||||
|
description: 'Show conversation history',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'limit',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'Number of messages to show',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'rest',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'export',
|
||||||
|
description: 'Export conversation to markdown or JSON',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'format',
|
||||||
|
type: 'enum',
|
||||||
|
optional: true,
|
||||||
|
values: ['md', 'json'],
|
||||||
|
description: 'Export format',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'rest',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preferences',
|
||||||
|
description: 'View or set user preferences',
|
||||||
|
aliases: ['pref'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'enum',
|
||||||
|
optional: true,
|
||||||
|
values: ['show', 'set', 'reset'],
|
||||||
|
description: 'Action to perform',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'rest',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'system',
|
||||||
|
description: 'Set session-scoped system prompt override',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'override',
|
||||||
|
type: 'string',
|
||||||
|
optional: false,
|
||||||
|
description: 'System prompt text to inject for this session',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
description: 'Show session and connection status',
|
||||||
|
aliases: ['s'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'hybrid',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show available commands',
|
||||||
|
aliases: ['h'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'Trigger garbage collection sweep (user-scoped)',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent',
|
||||||
|
description: 'Switch or list available agents',
|
||||||
|
aliases: ['a'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'args',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'list or <agent-id>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider',
|
||||||
|
description: 'Manage LLM providers (list/login/logout)',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'args',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'list | login <name> | logout <name>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'hybrid',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mission',
|
||||||
|
description: 'View or set active mission',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'args',
|
||||||
|
type: 'string',
|
||||||
|
optional: true,
|
||||||
|
description: 'status | set <id> | list | tasks',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'prdy',
|
||||||
|
description: 'Launch PRD wizard',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tools',
|
||||||
|
description: 'List available agent tools',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reload',
|
||||||
|
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'admin',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
apps/gateway/src/commands/commands.integration.spec.ts
Normal file
253
apps/gateway/src/commands/commands.integration.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the gateway command system (P8-019)
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - CommandRegistryService.getManifest() returns 12+ core commands
|
||||||
|
* - All core commands have correct execution types
|
||||||
|
* - Alias resolution works for all defined aliases
|
||||||
|
* - CommandExecutorService routes known/unknown commands correctly
|
||||||
|
* - /gc handler calls SessionGCService.sweepOrphans
|
||||||
|
* - /system handler calls SystemOverrideService.set
|
||||||
|
* - Unknown command returns descriptive error
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
import type { SlashCommandPayload } from '@mosaic/types';
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockAgentService = {
|
||||||
|
getSession: vi.fn(() => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSystemOverride = {
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
clear: vi.fn().mockResolvedValue(undefined),
|
||||||
|
renew: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSessionGC = {
|
||||||
|
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 3, totalCleaned: [], duration: 12 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRedis = {
|
||||||
|
set: vi.fn().mockResolvedValue('OK'),
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
del: vi.fn().mockResolvedValue(0),
|
||||||
|
keys: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildRegistry(): CommandRegistryService {
|
||||||
|
const svc = new CommandRegistryService();
|
||||||
|
svc.onModuleInit(); // seed core commands
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecutor(registry: CommandRegistryService): CommandExecutorService {
|
||||||
|
return new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
mockAgentService as never,
|
||||||
|
mockSystemOverride as never,
|
||||||
|
mockSessionGC as never,
|
||||||
|
mockRedis as never,
|
||||||
|
null, // reloadService (optional)
|
||||||
|
null, // chatGateway (optional)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Registry Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CommandRegistryService — integration', () => {
|
||||||
|
let registry: CommandRegistryService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = buildRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getManifest() returns 12 or more core commands after onModuleInit', () => {
|
||||||
|
const manifest = registry.getManifest();
|
||||||
|
expect(manifest.commands.length).toBeGreaterThanOrEqual(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest version is 1', () => {
|
||||||
|
expect(registry.getManifest().version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest.skills is an array', () => {
|
||||||
|
expect(Array.isArray(registry.getManifest().skills)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all commands have required fields: name, description, execution, scope, available', () => {
|
||||||
|
for (const cmd of registry.getManifest().commands) {
|
||||||
|
expect(typeof cmd.name).toBe('string');
|
||||||
|
expect(typeof cmd.description).toBe('string');
|
||||||
|
expect(['local', 'socket', 'rest', 'hybrid']).toContain(cmd.execution);
|
||||||
|
expect(['core', 'agent', 'admin']).toContain(cmd.scope);
|
||||||
|
expect(typeof cmd.available).toBe('boolean');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execution type verification for core commands
|
||||||
|
const expectedExecutionTypes: Record<string, string> = {
|
||||||
|
model: 'socket',
|
||||||
|
thinking: 'socket',
|
||||||
|
new: 'socket',
|
||||||
|
clear: 'socket',
|
||||||
|
compact: 'socket',
|
||||||
|
retry: 'socket',
|
||||||
|
rename: 'rest',
|
||||||
|
history: 'rest',
|
||||||
|
export: 'rest',
|
||||||
|
preferences: 'rest',
|
||||||
|
system: 'socket',
|
||||||
|
help: 'local',
|
||||||
|
gc: 'socket',
|
||||||
|
agent: 'socket',
|
||||||
|
provider: 'hybrid',
|
||||||
|
mission: 'socket',
|
||||||
|
prdy: 'socket',
|
||||||
|
tools: 'socket',
|
||||||
|
reload: 'socket',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, expectedExecution] of Object.entries(expectedExecutionTypes)) {
|
||||||
|
it(`command "${name}" has execution type "${expectedExecution}"`, () => {
|
||||||
|
const cmd = registry.getManifest().commands.find((c) => c.name === name);
|
||||||
|
expect(cmd, `command "${name}" not found`).toBeDefined();
|
||||||
|
expect(cmd!.execution).toBe(expectedExecution);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias resolution checks
|
||||||
|
const expectedAliases: Array<[string, string]> = [
|
||||||
|
['m', 'model'],
|
||||||
|
['t', 'thinking'],
|
||||||
|
['n', 'new'],
|
||||||
|
['a', 'agent'],
|
||||||
|
['s', 'status'],
|
||||||
|
['h', 'help'],
|
||||||
|
['pref', 'preferences'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [alias, commandName] of expectedAliases) {
|
||||||
|
it(`alias "/${alias}" resolves to command "${commandName}" via aliases array`, () => {
|
||||||
|
const cmd = registry
|
||||||
|
.getManifest()
|
||||||
|
.commands.find((c) => c.name === commandName || c.aliases?.includes(alias));
|
||||||
|
expect(cmd, `command with alias "${alias}" not found`).toBeDefined();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CommandExecutorService — integration', () => {
|
||||||
|
let registry: CommandRegistryService;
|
||||||
|
let executor: CommandExecutorService;
|
||||||
|
const userId = 'user-integ-001';
|
||||||
|
const conversationId = 'conv-integ-001';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
registry = buildRegistry();
|
||||||
|
executor = buildExecutor(registry);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unknown command returns error
|
||||||
|
it('unknown command returns success:false with descriptive message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('nonexistent');
|
||||||
|
expect(result.command).toBe('nonexistent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /gc handler calls SessionGCService.sweepOrphans
|
||||||
|
it('/gc calls SessionGCService.sweepOrphans with userId', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('GC sweep complete');
|
||||||
|
expect(result.message).toContain('3 orphaned sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /system with args calls SystemOverrideService.set
|
||||||
|
it('/system with text calls SystemOverrideService.set', async () => {
|
||||||
|
const override = 'You are a helpful assistant.';
|
||||||
|
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('override set');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /system with no args clears the override
|
||||||
|
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /model with model name returns success
|
||||||
|
it('/model with a model name returns success', async () => {
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'model',
|
||||||
|
args: 'claude-3-opus',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('model');
|
||||||
|
expect(result.message).toContain('claude-3-opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /thinking with valid level returns success
|
||||||
|
it('/thinking with valid level returns success', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /thinking with invalid level returns usage message
|
||||||
|
it('/thinking with invalid level returns usage message', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Usage:');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /new command returns success
|
||||||
|
it('/new returns success', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
// /reload without reloadService returns failure
|
||||||
|
it('/reload without ReloadService returns failure', async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('ReloadService');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commands not yet fully implemented return a fallback response
|
||||||
|
const stubCommands = ['clear', 'compact', 'retry'];
|
||||||
|
for (const cmd of stubCommands) {
|
||||||
|
it(`/${cmd} returns success (stub)`, async () => {
|
||||||
|
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||||
|
const result = await executor.execute(payload, userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.command).toBe(cmd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
37
apps/gateway/src/commands/commands.module.ts
Normal file
37
apps/gateway/src/commands/commands.module.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
|
useFactory: (): QueueHandle => {
|
||||||
|
return createQueue();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: COMMANDS_REDIS,
|
||||||
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
|
},
|
||||||
|
CommandRegistryService,
|
||||||
|
CommandExecutorService,
|
||||||
|
],
|
||||||
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
|
})
|
||||||
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/commands/commands.tokens.ts
Normal file
1
apps/gateway/src/commands/commands.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const COMMANDS_REDIS = 'COMMANDS_REDIS';
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/gateway/src/gc/gc.module.ts
Normal file
31
apps/gateway/src/gc/gc.module.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||||
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
|
const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: GC_QUEUE_HANDLE,
|
||||||
|
useFactory: (): QueueHandle => {
|
||||||
|
return createQueue();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: REDIS,
|
||||||
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
|
inject: [GC_QUEUE_HANDLE],
|
||||||
|
},
|
||||||
|
SessionGCService,
|
||||||
|
],
|
||||||
|
exports: [SessionGCService],
|
||||||
|
})
|
||||||
|
export class GCModule implements OnApplicationShutdown {
|
||||||
|
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
1
apps/gateway/src/gc/gc.tokens.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const REDIS = 'REDIS';
|
||||||
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
97
apps/gateway/src/gc/session-gc.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
|
import type { LogService } from '@mosaic/log';
|
||||||
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
|
|
||||||
|
type MockRedis = {
|
||||||
|
keys: ReturnType<typeof vi.fn>;
|
||||||
|
del: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SessionGCService', () => {
|
||||||
|
let service: SessionGCService;
|
||||||
|
let mockRedis: MockRedis;
|
||||||
|
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRedis = {
|
||||||
|
keys: vi.fn().mockResolvedValue([]),
|
||||||
|
del: vi.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService = {
|
||||||
|
logs: {
|
||||||
|
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suppress logger output in tests
|
||||||
|
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
service = new SessionGCService(
|
||||||
|
mockRedis as unknown as QueueHandle['redis'],
|
||||||
|
mockLogService as unknown as LogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collect() deletes Valkey keys for session', async () => {
|
||||||
|
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:abc:foo']);
|
||||||
|
const result = await service.collect('abc');
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||||
|
'mosaic:session:abc:system',
|
||||||
|
'mosaic:session:abc:foo',
|
||||||
|
);
|
||||||
|
expect(result.cleaned.valkeyKeys).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collect() with no keys returns empty cleaned valkeyKeys', async () => {
|
||||||
|
mockRedis.keys.mockResolvedValue([]);
|
||||||
|
const result = await service.collect('abc');
|
||||||
|
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collect() returns sessionId in result', async () => {
|
||||||
|
const result = await service.collect('test-session-id');
|
||||||
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fullCollect() deletes all session keys', async () => {
|
||||||
|
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||||
|
const result = await service.fullCollect();
|
||||||
|
expect(mockRedis.del).toHaveBeenCalled();
|
||||||
|
expect(result.valkeyKeys).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||||
|
mockRedis.keys.mockResolvedValue([]);
|
||||||
|
const result = await service.fullCollect();
|
||||||
|
expect(result.valkeyKeys).toBe(0);
|
||||||
|
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fullCollect() returns duration', async () => {
|
||||||
|
const result = await service.fullCollect();
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||||
|
mockRedis.keys.mockResolvedValue([
|
||||||
|
'mosaic:session:abc:system',
|
||||||
|
'mosaic:session:abc:messages',
|
||||||
|
'mosaic:session:xyz:system',
|
||||||
|
]);
|
||||||
|
mockRedis.del.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||||
|
mockRedis.keys.mockResolvedValue([]);
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBe(0);
|
||||||
|
expect(result.totalCleaned).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
139
apps/gateway/src/gc/session-gc.service.ts
Normal file
139
apps/gateway/src/gc/session-gc.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
|
import type { LogService } from '@mosaic/log';
|
||||||
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
|
export interface GCResult {
|
||||||
|
sessionId: string;
|
||||||
|
cleaned: {
|
||||||
|
valkeyKeys?: number;
|
||||||
|
logsDemoted?: number;
|
||||||
|
tempFilesRemoved?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GCSweepResult {
|
||||||
|
orphanedSessions: number;
|
||||||
|
totalCleaned: GCResult[];
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullGCResult {
|
||||||
|
valkeyKeys: number;
|
||||||
|
logsDemoted: number;
|
||||||
|
jobsPurged: number;
|
||||||
|
tempFilesRemoved: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionGCService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionGCService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
this.logger.log('Running full GC on cold start...');
|
||||||
|
const result = await this.fullCollect();
|
||||||
|
this.logger.log(
|
||||||
|
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||||
|
`${result.logsDemoted} logs demoted, ` +
|
||||||
|
`${result.jobsPurged} jobs purged, ` +
|
||||||
|
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||||
|
`(${result.duration}ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediate cleanup for a single session (call from destroySession).
|
||||||
|
*/
|
||||||
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
|
// 1. Valkey: delete all session-scoped keys
|
||||||
|
const pattern = `mosaic:session:${sessionId}:*`;
|
||||||
|
const valkeyKeys = await this.redis.keys(pattern);
|
||||||
|
if (valkeyKeys.length > 0) {
|
||||||
|
await this.redis.del(...valkeyKeys);
|
||||||
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||||
|
const cutoff = new Date(); // demote all hot logs for this session
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||||
|
if (logsDemoted > 0) {
|
||||||
|
result.cleaned.logsDemoted = logsDemoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||||
|
* User-scoped when userId provided; system-wide when null (admin).
|
||||||
|
*/
|
||||||
|
async sweepOrphans(_userId?: string): Promise<GCSweepResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const cleaned: GCResult[] = [];
|
||||||
|
|
||||||
|
// 1. Find all session-scoped Valkey keys
|
||||||
|
const allSessionKeys = await this.redis.keys('mosaic:session:*');
|
||||||
|
|
||||||
|
// Extract unique session IDs from keys
|
||||||
|
const sessionIds = new Set<string>();
|
||||||
|
for (const key of allSessionKeys) {
|
||||||
|
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||||
|
if (match) sessionIds.add(match[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each session ID, collect stale keys
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const gcResult = await this.collect(sessionId);
|
||||||
|
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||||
|
cleaned.push(gcResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orphanedSessions: cleaned.length,
|
||||||
|
totalCleaned: cleaned,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full GC — aggressive collection for cold start.
|
||||||
|
* Assumes no sessions survived the restart.
|
||||||
|
*/
|
||||||
|
async fullCollect(): Promise<FullGCResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 1. Valkey: delete ALL session-scoped keys
|
||||||
|
const sessionKeys = await this.redis.keys('mosaic:session:*');
|
||||||
|
if (sessionKeys.length > 0) {
|
||||||
|
await this.redis.del(...sessionKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. NOTE: channel keys are NOT collected on cold start
|
||||||
|
// (discord/telegram plugins may reconnect and resume)
|
||||||
|
|
||||||
|
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||||
|
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||||
|
|
||||||
|
// 4. No summarization job purge API available yet
|
||||||
|
const jobsPurged = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valkeyKeys: sessionKeys.length,
|
||||||
|
logsDemoted,
|
||||||
|
jobsPurged,
|
||||||
|
tempFilesRemoved: 0,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,22 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly tasks: cron.ScheduledTask[] = [];
|
private readonly tasks: cron.ScheduledTask[] = [];
|
||||||
|
|
||||||
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
constructor(
|
||||||
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||||
|
|
||||||
this.tasks.push(
|
this.tasks.push(
|
||||||
cron.schedule(summarizationSchedule, () => {
|
cron.schedule(summarizationSchedule, () => {
|
||||||
@@ -35,8 +40,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.tasks.push(
|
||||||
|
cron.schedule(gcSchedule, () => {
|
||||||
|
this.sessionGC.sweepOrphans().catch((err) => {
|
||||||
|
this.logger.error(`Session GC sweep failed: ${err}`);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { LOG_SERVICE } from './log.tokens.js';
|
|||||||
import { LogController } from './log.controller.js';
|
import { LogController } from './log.controller.js';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
import { CronService } from './cron.service.js';
|
import { CronService } from './cron.service.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [GCModule],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: LOG_SERVICE,
|
provide: LOG_SERVICE,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(helmet as never, { contentSecurityPolicy: false });
|
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,4 +2,10 @@ export interface IChannelPlugin {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
/** Called when a new project is bootstrapped. Return channelId if a channel was created. */
|
||||||
|
onProjectCreated?(project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<{ channelId: string } | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ class DiscordChannelPluginAdapter implements IChannelPlugin {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
await this.plugin.stop();
|
await this.plugin.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onProjectCreated(project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<{ channelId: string } | null> {
|
||||||
|
return this.plugin.createProjectChannel(project);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TelegramChannelPluginAdapter implements IChannelPlugin {
|
class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||||
|
|||||||
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PreferencesService } from './preferences.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
|
||||||
|
@Controller('api/preferences')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class PreferencesController {
|
||||||
|
constructor(@Inject(PreferencesService) private readonly preferences: PreferencesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async show(@CurrentUser() user: { id: string }): Promise<Record<string, unknown>> {
|
||||||
|
return this.preferences.getEffective(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async set(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Body() body: { key: string; value: unknown },
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
return this.preferences.set(user.id, body.key, body.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':key')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async reset(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('key') key: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
return this.preferences.reset(user.id, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PreferencesService } from './preferences.service.js';
|
||||||
|
import { PreferencesController } from './preferences.controller.js';
|
||||||
|
import { SystemOverrideService } from './system-override.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [PreferencesController],
|
||||||
|
providers: [PreferencesService, SystemOverrideService],
|
||||||
|
exports: [PreferencesService, SystemOverrideService],
|
||||||
|
})
|
||||||
|
export class PreferencesModule {}
|
||||||
167
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
167
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
||||||
|
import type { Db } from '@mosaic/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mock Drizzle DB where the select chain supports:
|
||||||
|
* db.select().from().where() → resolves to `listRows`
|
||||||
|
* db.select().from().where().limit(n) → resolves to `singleRow`
|
||||||
|
*/
|
||||||
|
function makeMockDb(
|
||||||
|
listRows: Array<{ key: string; value: unknown }> = [],
|
||||||
|
singleRow: Array<{ id: string }> = [],
|
||||||
|
): Db {
|
||||||
|
const chainWithLimit = {
|
||||||
|
limit: vi.fn().mockResolvedValue(singleRow),
|
||||||
|
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||||
|
};
|
||||||
|
const selectFrom = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||||
|
};
|
||||||
|
const updateResult = {
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const deleteResult = {
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const insertResult = {
|
||||||
|
values: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockReturnValue(selectFrom),
|
||||||
|
update: vi.fn().mockReturnValue(updateResult),
|
||||||
|
delete: vi.fn().mockReturnValue(deleteResult),
|
||||||
|
insert: vi.fn().mockReturnValue(insertResult),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PreferencesService', () => {
|
||||||
|
describe('getEffective', () => {
|
||||||
|
it('returns platform defaults when user has no overrides', async () => {
|
||||||
|
const db = makeMockDb([]);
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.getEffective('user-1');
|
||||||
|
|
||||||
|
expect(result['agent.thinkingLevel']).toBe('auto');
|
||||||
|
expect(result['agent.streamingEnabled']).toBe(true);
|
||||||
|
expect(result['session.autoCompactEnabled']).toBe(true);
|
||||||
|
expect(result['session.autoCompactThreshold']).toBe(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies user overrides for mutable keys', async () => {
|
||||||
|
const db = makeMockDb([
|
||||||
|
{ key: 'agent.thinkingLevel', value: 'high' },
|
||||||
|
{ key: 'response.language', value: 'es' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.getEffective('user-1');
|
||||||
|
|
||||||
|
expect(result['agent.thinkingLevel']).toBe('high');
|
||||||
|
expect(result['response.language']).toBe('es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores user overrides for immutable keys — enforcement always wins', async () => {
|
||||||
|
const db = makeMockDb([
|
||||||
|
{ key: 'limits.maxThinkingLevel', value: 'high' },
|
||||||
|
{ key: 'limits.rateLimit', value: 9999 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.getEffective('user-1');
|
||||||
|
|
||||||
|
// Should still be null (platform default), not the user-supplied values
|
||||||
|
expect(result['limits.maxThinkingLevel']).toBeNull();
|
||||||
|
expect(result['limits.rateLimit']).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set', () => {
|
||||||
|
it('returns error when attempting to override an immutable key', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
|
||||||
|
const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('platform enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when attempting to override limits.rateLimit', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
|
||||||
|
const result = await service.set('user-1', 'limits.rateLimit', 100);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('platform enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts a mutable preference and returns success — insert path', async () => {
|
||||||
|
// singleRow=[] → no existing row → insert path
|
||||||
|
const db = makeMockDb([], []);
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts a mutable preference and returns success — update path', async () => {
|
||||||
|
// singleRow has an id → existing row → update path
|
||||||
|
const db = makeMockDb([], [{ id: 'existing-id' }]);
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('returns error when attempting to reset an immutable key', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
|
||||||
|
const result = await service.reset('user-1', 'limits.rateLimit');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('platform enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes user override and returns default value in message', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.reset('user-1', 'agent.thinkingLevel');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IMMUTABLE_KEYS', () => {
|
||||||
|
it('contains only the enforcement keys', () => {
|
||||||
|
expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true);
|
||||||
|
expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true);
|
||||||
|
expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PLATFORM_DEFAULTS', () => {
|
||||||
|
it('has all expected keys', () => {
|
||||||
|
const expectedKeys = [
|
||||||
|
'agent.defaultModel',
|
||||||
|
'agent.thinkingLevel',
|
||||||
|
'agent.streamingEnabled',
|
||||||
|
'response.language',
|
||||||
|
'response.codeAnnotations',
|
||||||
|
'safety.confirmDestructiveTools',
|
||||||
|
'session.autoCompactThreshold',
|
||||||
|
'session.autoCompactEnabled',
|
||||||
|
'limits.maxThinkingLevel',
|
||||||
|
'limits.rateLimit',
|
||||||
|
];
|
||||||
|
for (const key of expectedKeys) {
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/gateway/src/preferences/preferences.service.ts
Normal file
119
apps/gateway/src/preferences/preferences.service.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
||||||
|
'agent.defaultModel': null,
|
||||||
|
'agent.thinkingLevel': 'auto',
|
||||||
|
'agent.streamingEnabled': true,
|
||||||
|
'response.language': 'auto',
|
||||||
|
'response.codeAnnotations': true,
|
||||||
|
'safety.confirmDestructiveTools': true,
|
||||||
|
'session.autoCompactThreshold': 0.8,
|
||||||
|
'session.autoCompactEnabled': true,
|
||||||
|
'limits.maxThinkingLevel': null,
|
||||||
|
'limits.rateLimit': null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IMMUTABLE_KEYS = new Set<string>(['limits.maxThinkingLevel', 'limits.rateLimit']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PreferencesService {
|
||||||
|
private readonly logger = new Logger(PreferencesService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the effective preference set for a user:
|
||||||
|
* Platform defaults → user overrides (mutable keys only) → enforcements re-applied last
|
||||||
|
*/
|
||||||
|
async getEffective(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const userPrefs = await this.getUserPrefs(userId);
|
||||||
|
const result: Record<string, unknown> = { ...PLATFORM_DEFAULTS };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(userPrefs)) {
|
||||||
|
if (!IMMUTABLE_KEYS.has(key)) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply immutable keys (enforcements always win)
|
||||||
|
for (const key of IMMUTABLE_KEYS) {
|
||||||
|
result[key] = PLATFORM_DEFAULTS[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (IMMUTABLE_KEYS.has(key)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertPref(userId, key, value);
|
||||||
|
return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (IMMUTABLE_KEYS.has(key)) {
|
||||||
|
return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deletePref(userId, key);
|
||||||
|
const defaultVal = PLATFORM_DEFAULTS[key];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserPrefs(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ key: preferencesTable.key, value: preferencesTable.value })
|
||||||
|
.from(preferencesTable)
|
||||||
|
.where(eq(preferencesTable.userId, userId));
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.key] = row.value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
const existing = await this.db
|
||||||
|
.select({ id: preferencesTable.id })
|
||||||
|
.from(preferencesTable)
|
||||||
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await this.db
|
||||||
|
.update(preferencesTable)
|
||||||
|
.set({ value: value as never, updatedAt: new Date() })
|
||||||
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||||
|
} else {
|
||||||
|
await this.db.insert(preferencesTable).values({
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
value: value as never,
|
||||||
|
mutable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deletePref(userId: string, key: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.delete(preferencesTable)
|
||||||
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||||
|
this.logger.debug(`Deleted preference "${key}" for user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apps/gateway/src/preferences/system-override.service.ts
Normal file
131
apps/gateway/src/preferences/system-override.service.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
|
|
||||||
|
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||||
|
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||||
|
`mosaic:session:${sessionId}:system:fragments`;
|
||||||
|
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||||
|
|
||||||
|
interface OverrideFragment {
|
||||||
|
text: string;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SystemOverrideService {
|
||||||
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
|
private readonly handle: QueueHandle;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.handle = createQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(sessionId: string, override: string): Promise<void> {
|
||||||
|
// Load existing fragments
|
||||||
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||||
|
const fragments: OverrideFragment[] = existing
|
||||||
|
? (JSON.parse(existing) as OverrideFragment[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Append new fragment
|
||||||
|
fragments.push({ text: override, addedAt: Date.now() });
|
||||||
|
|
||||||
|
// Condense fragments into one coherent override
|
||||||
|
const texts = fragments.map((f) => f.text);
|
||||||
|
const condensed = await this.condenseOverrides(texts);
|
||||||
|
|
||||||
|
// Store both: fragments array and condensed result
|
||||||
|
const pipeline = this.handle.redis.pipeline();
|
||||||
|
pipeline.setex(
|
||||||
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
|
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||||
|
JSON.stringify(fragments),
|
||||||
|
);
|
||||||
|
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||||
|
await pipeline.exec();
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Set system override for session ${sessionId} (${fragments.length} fragment(s), TTL=${SYSTEM_OVERRIDE_TTL_SECONDS}s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(sessionId: string): Promise<string | null> {
|
||||||
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async renew(sessionId: string): Promise<void> {
|
||||||
|
const pipeline = this.handle.redis.pipeline();
|
||||||
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
|
await pipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(sessionId: string): Promise<void> {
|
||||||
|
await this.handle.redis.del(
|
||||||
|
SESSION_SYSTEM_KEY(sessionId),
|
||||||
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
|
);
|
||||||
|
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an array of override fragments into one coherent string.
|
||||||
|
* If only one fragment exists, returns it as-is.
|
||||||
|
* For multiple fragments, calls Haiku to produce a merged instruction.
|
||||||
|
* Falls back to newline concatenation if the LLM call fails.
|
||||||
|
*/
|
||||||
|
async condenseOverrides(fragments: string[]): Promise<string> {
|
||||||
|
if (fragments.length === 0) return '';
|
||||||
|
if (fragments.length === 1) return fragments[0]!;
|
||||||
|
|
||||||
|
const numbered = fragments.map((f, i) => `${i + 1}. ${f}`).join('\n');
|
||||||
|
const prompt =
|
||||||
|
`Merge these system prompt instructions into one coherent paragraph. ` +
|
||||||
|
`If instructions conflict, favor the most recently added (last in the list). ` +
|
||||||
|
`Be concise — output only the merged instruction, nothing else.\n\n` +
|
||||||
|
`Instructions (oldest first):\n${numbered}`;
|
||||||
|
|
||||||
|
const apiKey = process.env['ANTHROPIC_API_KEY'];
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.warn('ANTHROPIC_API_KEY not set — falling back to newline concatenation');
|
||||||
|
return fragments.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-haiku-4-5-20251001',
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Anthropic API error ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
content: Array<{ type: string; text: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const textBlock = data.content.find((c) => c.type === 'text');
|
||||||
|
if (!textBlock) {
|
||||||
|
throw new Error('No text block in Anthropic response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return textBlock.text.trim();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Condensation LLM call failed — falling back to newline concatenation: ${String(err)}`,
|
||||||
|
);
|
||||||
|
return fragments.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -16,22 +17,25 @@ 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 { TeamsService } from '../workspace/teams.service.js';
|
||||||
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
||||||
|
|
||||||
@Controller('api/projects')
|
@Controller('api/projects')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
constructor(
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
|
private readonly teamsService: TeamsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list(@CurrentUser() user: { id: string }) {
|
||||||
return this.brain.projects.findAll();
|
return this.brain.projects.findAllForUser(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.getOwnedProject(id, user.id);
|
return this.getAccessibleProject(id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -50,7 +54,7 @@ export class ProjectsController {
|
|||||||
@Body() dto: UpdateProjectDto,
|
@Body() dto: UpdateProjectDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
await this.getOwnedProject(id, user.id);
|
await this.getAccessibleProject(id, user.id);
|
||||||
const project = await this.brain.projects.update(id, dto);
|
const project = await this.brain.projects.update(id, dto);
|
||||||
if (!project) throw new NotFoundException('Project not found');
|
if (!project) throw new NotFoundException('Project not found');
|
||||||
return project;
|
return project;
|
||||||
@@ -59,15 +63,21 @@ export class ProjectsController {
|
|||||||
@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.getOwnedProject(id, user.id);
|
await this.getAccessibleProject(id, user.id);
|
||||||
const deleted = await this.brain.projects.remove(id);
|
const deleted = await this.brain.projects.remove(id);
|
||||||
if (!deleted) throw new NotFoundException('Project not found');
|
if (!deleted) throw new NotFoundException('Project not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOwnedProject(id: string, userId: string) {
|
/**
|
||||||
|
* Verify the requesting user can access the project — either as the direct
|
||||||
|
* owner or as a member of the owning team. Throws NotFoundException when the
|
||||||
|
* project does not exist and ForbiddenException when the user lacks access.
|
||||||
|
*/
|
||||||
|
private async getAccessibleProject(id: string, userId: string) {
|
||||||
const project = await this.brain.projects.findById(id);
|
const project = await this.brain.projects.findById(id);
|
||||||
if (!project) throw new NotFoundException('Project not found');
|
if (!project) throw new NotFoundException('Project not found');
|
||||||
assertOwner(project.ownerId, userId, 'Project');
|
const canAccess = await this.teamsService.canAccessProject(userId, id);
|
||||||
|
if (!canAccess) throw new ForbiddenException('Project does not belong to the current user');
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ProjectsController } from './projects.controller.js';
|
import { ProjectsController } from './projects.controller.js';
|
||||||
|
import { WorkspaceModule } from '../workspace/workspace.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [WorkspaceModule],
|
||||||
controllers: [ProjectsController],
|
controllers: [ProjectsController],
|
||||||
})
|
})
|
||||||
export class ProjectsModule {}
|
export class ProjectsModule {}
|
||||||
|
|||||||
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface MosaicPlugin {
|
||||||
|
/** Called when the plugin is loaded/reloaded */
|
||||||
|
onLoad(): Promise<void>;
|
||||||
|
|
||||||
|
/** Called before the plugin is unloaded during reload */
|
||||||
|
onUnload(): Promise<void>;
|
||||||
|
|
||||||
|
/** Plugin identifier for registry */
|
||||||
|
readonly pluginName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMosaicPlugin(obj: unknown): obj is MosaicPlugin {
|
||||||
|
return (
|
||||||
|
typeof obj === 'object' &&
|
||||||
|
obj !== null &&
|
||||||
|
typeof (obj as MosaicPlugin).onLoad === 'function' &&
|
||||||
|
typeof (obj as MosaicPlugin).onUnload === 'function' &&
|
||||||
|
typeof (obj as MosaicPlugin).pluginName === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/gateway/src/reload/reload.controller.ts
Normal file
22
apps/gateway/src/reload/reload.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import type { SystemReloadPayload } from '@mosaic/types';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { ReloadService } from './reload.service.js';
|
||||||
|
|
||||||
|
@Controller('api/admin')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
export class ReloadController {
|
||||||
|
constructor(
|
||||||
|
@Inject(ReloadService) private readonly reloadService: ReloadService,
|
||||||
|
@Inject(ChatGateway) private readonly chatGateway: ChatGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('reload')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async triggerReload(): Promise<SystemReloadPayload> {
|
||||||
|
const result = await this.reloadService.reload('rest');
|
||||||
|
this.chatGateway.broadcastReload(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/gateway/src/reload/reload.module.ts
Normal file
14
apps/gateway/src/reload/reload.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { ReloadController } from './reload.controller.js';
|
||||||
|
import { ReloadService } from './reload.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => CommandsModule), forwardRef(() => ChatModule)],
|
||||||
|
controllers: [ReloadController],
|
||||||
|
providers: [ReloadService, AdminGuard],
|
||||||
|
exports: [ReloadService],
|
||||||
|
})
|
||||||
|
export class ReloadModule {}
|
||||||
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReloadService } from './reload.service.js';
|
||||||
|
|
||||||
|
function createMockCommandRegistry() {
|
||||||
|
return {
|
||||||
|
getManifest: vi.fn().mockReturnValue({
|
||||||
|
version: 1,
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
const registry = createMockCommandRegistry();
|
||||||
|
const service = new ReloadService(registry as never);
|
||||||
|
return { service, registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReloadService', () => {
|
||||||
|
it('reload() calls onUnload then onLoad for registered MosaicPlugin', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const mockPlugin = {
|
||||||
|
pluginName: 'test-plugin',
|
||||||
|
onLoad: vi.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('onLoad');
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
onUnload: vi.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('onUnload');
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
service.registerPlugin('test-plugin', mockPlugin);
|
||||||
|
const result = await service.reload('command');
|
||||||
|
|
||||||
|
expect(mockPlugin.onUnload).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPlugin.onLoad).toHaveBeenCalledOnce();
|
||||||
|
expect(callOrder).toEqual(['onUnload', 'onLoad']);
|
||||||
|
expect(result.message).toContain('test-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() continues if one plugin throws during onUnload', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
const badPlugin = {
|
||||||
|
pluginName: 'bad-plugin',
|
||||||
|
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onUnload: vi.fn().mockRejectedValue(new Error('unload failed')),
|
||||||
|
};
|
||||||
|
|
||||||
|
service.registerPlugin('bad-plugin', badPlugin);
|
||||||
|
const result = await service.reload('command');
|
||||||
|
|
||||||
|
expect(result.message).toContain('bad-plugin');
|
||||||
|
expect(result.message).toContain('unload failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() skips non-MosaicPlugin objects', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
const notAPlugin = { foo: 'bar' };
|
||||||
|
service.registerPlugin('not-a-plugin', notAPlugin);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
const result = await service.reload('command');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.message).not.toContain('not-a-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() returns SystemReloadPayload with commands, skills, providers, message', async () => {
|
||||||
|
const { service, registry } = createService();
|
||||||
|
registry.getManifest.mockReturnValue({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
description: 'test cmd',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.reload('rest');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('commands');
|
||||||
|
expect(result).toHaveProperty('skills');
|
||||||
|
expect(result).toHaveProperty('providers');
|
||||||
|
expect(result).toHaveProperty('message');
|
||||||
|
expect(result.commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registerPlugin() logs plugin registration', () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
// Should not throw and should register
|
||||||
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
apps/gateway/src/reload/reload.service.ts
Normal file
92
apps/gateway/src/reload/reload.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnApplicationBootstrap,
|
||||||
|
type OnApplicationShutdown,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { SystemReloadPayload } from '@mosaic/types';
|
||||||
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
|
import { isMosaicPlugin } from './mosaic-plugin.interface.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReloadService implements OnApplicationBootstrap, OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger(ReloadService.name);
|
||||||
|
private readonly plugins: Map<string, unknown> = new Map();
|
||||||
|
private shutdownHandlerAttached = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onApplicationBootstrap(): void {
|
||||||
|
if (!this.shutdownHandlerAttached) {
|
||||||
|
process.on('SIGHUP', () => {
|
||||||
|
this.logger.log('SIGHUP received — triggering soft reload');
|
||||||
|
this.reload('sighup').catch((err: unknown) => {
|
||||||
|
this.logger.error(`SIGHUP reload failed: ${err}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.shutdownHandlerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplicationShutdown(): void {
|
||||||
|
process.removeAllListeners('SIGHUP');
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPlugin(name: string, plugin: unknown): void {
|
||||||
|
this.plugins.set(name, plugin);
|
||||||
|
this.logger.log(`Plugin registered: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft reload — unload plugins, reload plugins, broadcast.
|
||||||
|
* Does NOT restart the HTTP server or drop connections.
|
||||||
|
*/
|
||||||
|
async reload(
|
||||||
|
trigger: 'command' | 'rest' | 'sighup' | 'file-watch',
|
||||||
|
): Promise<SystemReloadPayload> {
|
||||||
|
this.logger.log(`Soft reload triggered by: ${trigger}`);
|
||||||
|
const reloaded: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Unload all registered MosaicPlugin instances
|
||||||
|
for (const [name, plugin] of this.plugins) {
|
||||||
|
if (isMosaicPlugin(plugin)) {
|
||||||
|
try {
|
||||||
|
await plugin.onUnload();
|
||||||
|
reloaded.push(name);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${name}: unload failed — ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reload all MosaicPlugin instances
|
||||||
|
for (const [name, plugin] of this.plugins) {
|
||||||
|
if (isMosaicPlugin(plugin)) {
|
||||||
|
try {
|
||||||
|
await plugin.onLoad();
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${name}: load failed — ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = this.commandRegistry.getManifest();
|
||||||
|
|
||||||
|
const errorSuffix = errors.length > 0 ? ` Errors: ${errors.join(', ')}` : '';
|
||||||
|
const payload: SystemReloadPayload = {
|
||||||
|
commands: manifest.commands,
|
||||||
|
skills: manifest.skills,
|
||||||
|
providers: [],
|
||||||
|
message: `Reload complete (trigger=${trigger}). Plugins reloaded: [${reloaded.join(', ')}].${errorSuffix}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Reload complete. Reloaded: [${reloaded.join(', ')}]. Errors: ${errors.length}`,
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/gateway/src/workspace/project-bootstrap.service.ts
Normal file
98
apps/gateway/src/workspace/project-bootstrap.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Brain } from '@mosaic/brain';
|
||||||
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
|
import { PluginService } from '../plugin/plugin.service.js';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
|
||||||
|
export interface BootstrapProjectParams {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
userId: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapProjectResult {
|
||||||
|
projectId: string;
|
||||||
|
workspacePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectBootstrapService {
|
||||||
|
private readonly logger = new Logger(ProjectBootstrapService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
|
private readonly workspace: WorkspaceService,
|
||||||
|
private readonly pluginService: PluginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap a new project: create DB record + workspace directory.
|
||||||
|
* Returns the created project with its workspace path.
|
||||||
|
*/
|
||||||
|
async bootstrap(params: BootstrapProjectParams): Promise<BootstrapProjectResult> {
|
||||||
|
const ownerType: 'user' | 'team' = params.teamId ? 'team' : 'user';
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Bootstrapping project "${params.name}" for ${ownerType} ${params.teamId ?? params.userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Create DB record
|
||||||
|
const project = await this.brain.projects.create({
|
||||||
|
name: params.name,
|
||||||
|
description: params.description,
|
||||||
|
ownerId: params.userId,
|
||||||
|
teamId: params.teamId ?? null,
|
||||||
|
ownerType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create workspace directory (includes docs structure)
|
||||||
|
const workspacePath = await this.workspace.create(
|
||||||
|
{
|
||||||
|
id: project.id,
|
||||||
|
ownerType,
|
||||||
|
userId: params.userId,
|
||||||
|
teamId: params.teamId ?? null,
|
||||||
|
},
|
||||||
|
params.repoUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Create default agent config for the project
|
||||||
|
await this.brain.agents.create({
|
||||||
|
name: 'default',
|
||||||
|
provider: '',
|
||||||
|
model: '',
|
||||||
|
projectId: project.id,
|
||||||
|
ownerId: params.userId,
|
||||||
|
isSystem: false,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Notify plugins so they can set up project-specific resources (e.g. Discord channel)
|
||||||
|
try {
|
||||||
|
for (const plugin of this.pluginService.getPlugins()) {
|
||||||
|
if (plugin.onProjectCreated) {
|
||||||
|
const result = await plugin.onProjectCreated({
|
||||||
|
id: project.id,
|
||||||
|
name: params.name,
|
||||||
|
description: params.description,
|
||||||
|
});
|
||||||
|
if (result?.channelId) {
|
||||||
|
await this.brain.projects.update(project.id, {
|
||||||
|
metadata: { discordChannelId: result.channelId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Plugin project notification failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Project ${project.id} bootstrapped at ${workspacePath}`);
|
||||||
|
|
||||||
|
return { projectId: project.id, workspacePath };
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/gateway/src/workspace/teams.controller.ts
Normal file
30
apps/gateway/src/workspace/teams.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
|
@Controller('api/teams')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class TeamsController {
|
||||||
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
return this.teams.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId')
|
||||||
|
async findOne(@Param('teamId') teamId: string) {
|
||||||
|
return this.teams.findById(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId/members')
|
||||||
|
async listMembers(@Param('teamId') teamId: string) {
|
||||||
|
return this.teams.listMembers(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId/members/:userId')
|
||||||
|
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||||
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
|
return { isMember };
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/gateway/src/workspace/teams.service.ts
Normal file
73
apps/gateway/src/workspace/teams.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaic/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
private readonly logger = new Logger(TeamsService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is a member of a team.
|
||||||
|
*/
|
||||||
|
async isMember(teamId: string, userId: string): Promise<boolean> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: teamMembers.id })
|
||||||
|
.from(teamMembers)
|
||||||
|
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check project access for a user.
|
||||||
|
* - ownerType === 'user': project.ownerId must equal userId
|
||||||
|
* - ownerType === 'team': userId must be a member of project.teamId
|
||||||
|
*/
|
||||||
|
async canAccessProject(userId: string, projectId: string): Promise<boolean> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
ownerType: projects.ownerType,
|
||||||
|
ownerId: projects.ownerId,
|
||||||
|
teamId: projects.teamId,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId));
|
||||||
|
|
||||||
|
const project = rows[0];
|
||||||
|
if (!project) return false;
|
||||||
|
|
||||||
|
if (project.ownerType === 'user') {
|
||||||
|
return project.ownerId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.ownerType === 'team' && project.teamId) {
|
||||||
|
return this.isMember(project.teamId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all teams (for admin/listing endpoints).
|
||||||
|
*/
|
||||||
|
async findAll() {
|
||||||
|
return this.db.select().from(teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a team by ID.
|
||||||
|
*/
|
||||||
|
async findById(id: string) {
|
||||||
|
const rows = await this.db.select().from(teams).where(eq(teams.id, id));
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List members of a team.
|
||||||
|
*/
|
||||||
|
async listMembers(teamId: string) {
|
||||||
|
return this.db.select().from(teamMembers).where(eq(teamMembers.teamId, teamId));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/gateway/src/workspace/workspace.controller.ts
Normal file
30
apps/gateway/src/workspace/workspace.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
|
||||||
|
@Controller('api/workspaces')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class WorkspaceController {
|
||||||
|
constructor(private readonly bootstrap: ProjectBootstrapService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.bootstrap.bootstrap({
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: body.teamId,
|
||||||
|
repoUrl: body.repoUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/gateway/src/workspace/workspace.module.ts
Normal file
13
apps/gateway/src/workspace/workspace.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
import { TeamsService } from './teams.service.js';
|
||||||
|
import { WorkspaceController } from './workspace.controller.js';
|
||||||
|
import { TeamsController } from './teams.controller.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WorkspaceController, TeamsController],
|
||||||
|
providers: [WorkspaceService, ProjectBootstrapService, TeamsService],
|
||||||
|
exports: [WorkspaceService, ProjectBootstrapService, TeamsService],
|
||||||
|
})
|
||||||
|
export class WorkspaceModule {}
|
||||||
79
apps/gateway/src/workspace/workspace.service.spec.ts
Normal file
79
apps/gateway/src/workspace/workspace.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
describe('WorkspaceService', () => {
|
||||||
|
let service: WorkspaceService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new WorkspaceService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePath', () => {
|
||||||
|
it('resolves user workspace path', () => {
|
||||||
|
const result = service.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves team workspace path', () => {
|
||||||
|
const result = service.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'team',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: 'team1',
|
||||||
|
});
|
||||||
|
expect(result).toContain(path.join('teams', 'team1', 'proj1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to user path when ownerType is team but teamId is null', () => {
|
||||||
|
const result = service.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'team',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses MOSAIC_ROOT env var as the base path', () => {
|
||||||
|
const originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
process.env['MOSAIC_ROOT'] = '/custom/root';
|
||||||
|
const customService = new WorkspaceService();
|
||||||
|
const result = customService.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/^\/custom\/root/);
|
||||||
|
// Restore
|
||||||
|
if (originalRoot === undefined) {
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => {
|
||||||
|
const originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
const defaultService = new WorkspaceService();
|
||||||
|
const result = defaultService.resolvePath({
|
||||||
|
id: 'proj2',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user2',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/^\/opt\/mosaic/);
|
||||||
|
// Restore
|
||||||
|
if (originalRoot !== undefined) {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
116
apps/gateway/src/workspace/workspace.service.ts
Normal file
116
apps/gateway/src/workspace/workspace.service.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface WorkspaceProject {
|
||||||
|
id: string;
|
||||||
|
ownerType: 'user' | 'team';
|
||||||
|
userId: string;
|
||||||
|
teamId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceService {
|
||||||
|
private readonly logger = new Logger(WorkspaceService.name);
|
||||||
|
private readonly mosaicRoot: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.mosaicRoot = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the workspace path for a project.
|
||||||
|
* Solo: $MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>/
|
||||||
|
* Team: $MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>/
|
||||||
|
*/
|
||||||
|
resolvePath(project: WorkspaceProject): string {
|
||||||
|
if (project.ownerType === 'team' && project.teamId) {
|
||||||
|
return path.join(this.mosaicRoot, '.workspaces', 'teams', project.teamId, project.id);
|
||||||
|
}
|
||||||
|
return path.join(this.mosaicRoot, '.workspaces', 'users', project.userId, project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a workspace directory and initialize it as a git repo.
|
||||||
|
* If repoUrl is provided, clone instead of init.
|
||||||
|
*/
|
||||||
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
|
if (repoUrl) {
|
||||||
|
// Clone existing repo
|
||||||
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
|
} else {
|
||||||
|
// Init new git repo
|
||||||
|
await execFileAsync('git', ['init'], { cwd: workspacePath });
|
||||||
|
await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial workspace commit'], {
|
||||||
|
cwd: workspacePath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'Mosaic',
|
||||||
|
GIT_AUTHOR_EMAIL: 'mosaic@localhost',
|
||||||
|
GIT_COMMITTER_NAME: 'Mosaic',
|
||||||
|
GIT_COMMITTER_EMAIL: 'mosaic@localhost',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Initialized git workspace at ${workspacePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create standard docs structure
|
||||||
|
await fs.mkdir(path.join(workspacePath, 'docs', 'plans'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(workspacePath, 'docs', 'reports'), { recursive: true });
|
||||||
|
this.logger.log(`Created docs structure at ${workspacePath}`);
|
||||||
|
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a workspace directory recursively.
|
||||||
|
*/
|
||||||
|
async delete(project: WorkspaceProject): Promise<void> {
|
||||||
|
const workspacePath = this.resolvePath(project);
|
||||||
|
try {
|
||||||
|
await fs.rm(workspacePath, { recursive: true, force: true });
|
||||||
|
this.logger.log(`Deleted workspace at ${workspacePath}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to delete workspace at ${workspacePath}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the workspace directory exists.
|
||||||
|
*/
|
||||||
|
async exists(project: WorkspaceProject): Promise<boolean> {
|
||||||
|
const workspacePath = this.resolvePath(project);
|
||||||
|
try {
|
||||||
|
await fs.access(workspacePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the base user workspace directory (call on user registration).
|
||||||
|
*/
|
||||||
|
async createUserRoot(userId: string): Promise<void> {
|
||||||
|
const userRoot = path.join(this.mosaicRoot, '.workspaces', 'users', userId);
|
||||||
|
await fs.mkdir(userRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the base team workspace directory (call on team creation).
|
||||||
|
*/
|
||||||
|
async createTeamRoot(teamId: string): Promise<void> {
|
||||||
|
const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId);
|
||||||
|
await fs.mkdir(teamRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
const workosEnabled = process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||||
|
const keycloakEnabled = process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||||
|
const hasSsoProviders = workosEnabled || keycloakEnabled;
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -30,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
router.push('/chat');
|
router.push('/chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSsoSignIn(providerId: string): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
const result = await signIn.oauth2({ providerId, callbackURL: '/chat' });
|
||||||
|
if (result?.error) {
|
||||||
|
setError(result.error.message ?? 'SSO sign in failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
@@ -44,7 +58,37 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
{hasSsoProviders && (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{workosEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleSsoSignIn('workos')}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continue with WorkOS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{keycloakEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleSsoSignIn('keycloak')}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continue with Keycloak
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
|
<span className="mx-3 text-xs text-text-muted">or</span>
|
||||||
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
Email
|
Email
|
||||||
|
|||||||
@@ -151,11 +151,15 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
try {
|
||||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
||||||
if (activeId === id) {
|
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||||
setActiveId(null);
|
if (activeId === id) {
|
||||||
setMessages([]);
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChatPage] Failed to delete conversation:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeId],
|
[activeId],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||||
plugins: [adminClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
export const { useSession, signIn, signUp, signOut } = authClient;
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,39 +7,39 @@
|
|||||||
|
|
||||||
**ID:** mvp-20260312
|
**ID:** mvp-20260312
|
||||||
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
|
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
|
||||||
**Phase:** Execution
|
**Phase:** Complete
|
||||||
**Current Milestone:** Phase 8: Polish & Beta (v0.1.0)
|
**Current Milestone:** Phase 8: Polish & Beta (v0.1.0) — DONE
|
||||||
**Progress:** 8 / 9 milestones
|
**Progress:** 9 / 9 milestones
|
||||||
**Status:** active
|
**Status:** complete
|
||||||
**Last Updated:** 2026-03-15 UTC
|
**Last Updated:** 2026-03-16 UTC
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- [ ] AC-1: Core chat flow — login, send message, streamed response, conversations persist
|
- [x] AC-1: Core chat flow — login, send message, streamed response, conversations persist
|
||||||
- [ ] AC-2: TUI integration — `mosaic tui` connects to gateway, same context as web
|
- [x] AC-2: TUI integration — `mosaic tui` connects to gateway, same context as web
|
||||||
- [ ] AC-3: Discord remote control — bot responds, routes through gateway, threads work
|
- [x] AC-3: Discord remote control — bot responds, routes through gateway, threads work
|
||||||
- [ ] AC-4: Gateway orchestration — multi-provider routing, fallback, concurrent sessions
|
- [x] AC-4: Gateway orchestration — multi-provider routing, fallback, concurrent sessions
|
||||||
- [ ] AC-5: Task & project management — CRUD, kanban, mission tracking, brain MCP tools
|
- [x] AC-5: Task & project management — CRUD, kanban, mission tracking, brain MCP tools
|
||||||
- [ ] AC-6: Memory system — auto-capture, semantic search, preferences, log summarization
|
- [x] AC-6: Memory system — auto-capture, semantic search, preferences, log summarization
|
||||||
- [ ] AC-7: Auth & RBAC — email/password, Authentik SSO, role enforcement
|
- [x] AC-7: Auth & RBAC — email/password, Authentik SSO, role enforcement
|
||||||
- [ ] AC-8: Multi-provider LLM — 3+ providers routing correctly
|
- [x] AC-8: Multi-provider LLM — 3+ providers routing correctly
|
||||||
- [ ] AC-9: MCP — gateway MCP endpoint, brain + queue tools via MCP
|
- [x] AC-9: MCP — gateway MCP endpoint, brain + queue tools via MCP
|
||||||
- [ ] AC-10: Deployment — `docker compose up` from clean state, CLI on bare metal
|
- [x] AC-10: Deployment — `docker compose up` from clean state, CLI on bare metal
|
||||||
- [ ] AC-11: @mosaic/\* packages — all 7 migrated packages build, test, integrate
|
- [x] AC-11: @mosaic/\* packages — all 7 migrated packages build, test, integrate
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ------ | --------------------------------------- | ----------- | ------ | ----- | ---------- | ---------- |
|
| --- | ------ | --------------------------------------- | ------ | ------ | ----- | ---------- | ---------- |
|
||||||
| 0 | ms-157 | Phase 0: Foundation (v0.0.1) | done | — | — | 2026-03-13 | 2026-03-13 |
|
| 0 | ms-157 | Phase 0: Foundation (v0.0.1) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||||
| 1 | ms-158 | Phase 1: Core API (v0.0.2) | done | — | — | 2026-03-13 | 2026-03-13 |
|
| 1 | ms-158 | Phase 1: Core API (v0.0.2) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||||
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
|
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
|
||||||
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
||||||
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
||||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
||||||
| 7 | ms-164 | Phase 7: Feature Completion (v0.0.8) | done | — | — | 2026-03-15 | 2026-03-15 |
|
| 7 | ms-164 | Phase 7: Feature Completion (v0.0.8) | done | — | — | 2026-03-15 | 2026-03-15 |
|
||||||
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
| 8 | ms-165 | Phase 8: Polish & Beta (v0.1.0) | done | — | — | 2026-03-15 | 2026-03-15 |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -58,20 +58,21 @@
|
|||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
| ------- | --------------- | -------------------- | -------- | ------------- | ---------------- |
|
| ------- | ----------------- | -------------------- | -------- | ------------- | ---------------- |
|
||||||
| 1 | claude-opus-4-6 | 2026-03-13 01:00 UTC | — | context limit | Planning gate |
|
| 1 | claude-opus-4-6 | 2026-03-13 01:00 UTC | — | context limit | Planning gate |
|
||||||
| 2 | claude-opus-4-6 | 2026-03-13 | — | context limit | P5-002, P6-005 |
|
| 2 | claude-opus-4-6 | 2026-03-13 | — | context limit | P5-002, P6-005 |
|
||||||
| 3 | claude-opus-4-6 | 2026-03-13 | — | context limit | P0-006 |
|
| 3 | claude-opus-4-6 | 2026-03-13 | — | context limit | P0-006 |
|
||||||
| 4 | claude-opus-4-6 | 2026-03-12 | — | context limit | Docker fix |
|
| 4 | claude-opus-4-6 | 2026-03-12 | — | context limit | Docker fix |
|
||||||
| 5 | claude-opus-4-6 | 2026-03-12 | — | context limit | P1-009 |
|
| 5 | claude-opus-4-6 | 2026-03-12 | — | context limit | P1-009 |
|
||||||
| 6 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-006, FIX-01 |
|
| 6 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-006, FIX-01 |
|
||||||
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
|
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
|
||||||
| 8 | claude-opus-4-6 | 2026-03-12 | — | context limit | Phase 2 complete |
|
| 8 | claude-opus-4-6 | 2026-03-12 | — | context limit | Phase 2 complete |
|
||||||
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
|
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
|
||||||
| 10 | claude-opus-4-6 | 2026-03-13 | — | context limit | P3-008 |
|
| 10 | claude-opus-4-6 | 2026-03-13 | — | context limit | P3-008 |
|
||||||
| 11 | claude-opus-4-6 | 2026-03-14 | — | context limit | P7 rescope |
|
| 11 | claude-opus-4-6 | 2026-03-14 | — | context limit | P7 rescope |
|
||||||
| 12 | claude-opus-4-6 | 2026-03-15 | — | active | P7 planning |
|
| 12 | claude-opus-4-6 | 2026-03-15 | — | context limit | P7 planning |
|
||||||
|
| 13 | claude-sonnet-4-6 | 2026-03-16 | — | complete | P8-019 verify |
|
||||||
|
|
||||||
## Scratchpad
|
## Scratchpad
|
||||||
|
|
||||||
|
|||||||
70
docs/PRD-TUI_Improvements.md
Normal file
70
docs/PRD-TUI_Improvements.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# PRD: TUI Improvements — Phase 7
|
||||||
|
|
||||||
|
**Branch:** `feat/p7-tui-improvements`
|
||||||
|
**Package:** `packages/cli`
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current Mosaic CLI TUI (`packages/cli/src/tui/app.tsx`) is a minimal single-file Ink application with:
|
||||||
|
|
||||||
|
- Flat message list with no visual hierarchy
|
||||||
|
- No system context visibility (cwd, branch, model, tokens)
|
||||||
|
- Noisy error messages when gateway is disconnected
|
||||||
|
- No conversation management (list, switch, rename, delete)
|
||||||
|
- No multi-panel layout or navigation
|
||||||
|
- No tool call visibility during agent execution
|
||||||
|
- No thinking/reasoning display
|
||||||
|
|
||||||
|
The TUI should be the power-user interface to Mosaic — informative, responsive, and visually clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
### Wave 1 — Status Bar & Polish (MVP)
|
||||||
|
|
||||||
|
Provide essential context at a glance and reduce noise.
|
||||||
|
|
||||||
|
1. **Top status bar** — shows: connection indicator (●/○), gateway URL, agent model name
|
||||||
|
2. **Bottom status bar** — shows: cwd, git branch, token usage (input/output/total)
|
||||||
|
3. **Better message formatting** — distinct visual treatment for user vs assistant messages, timestamps, word wrap
|
||||||
|
4. **Quiet disconnect** — single-line indicator when gateway is offline instead of flooding error messages; auto-reconnect silently
|
||||||
|
5. **Tool call display** — inline indicators when agent uses tools (spinner + tool name during execution, ✓/✗ on completion)
|
||||||
|
6. **Thinking/reasoning display** — collapsible dimmed block for `agent:thinking` events
|
||||||
|
|
||||||
|
### Wave 2 — Layout & Navigation
|
||||||
|
|
||||||
|
Multi-panel layout with keyboard navigation.
|
||||||
|
|
||||||
|
1. **Conversation sidebar** — list conversations, create new, switch between them
|
||||||
|
2. **Keybinding system** — Ctrl+N (new conversation), Ctrl+L (conversation list toggle), Ctrl+K (command palette concept)
|
||||||
|
3. **Scrollable message history** — viewport with PgUp/PgDn/arrow key scrolling
|
||||||
|
4. **Message search** — find in current conversation
|
||||||
|
|
||||||
|
### Wave 3 — Advanced Features
|
||||||
|
|
||||||
|
1. **Project/mission views** — show active projects, missions, tasks
|
||||||
|
2. **Agent status monitoring** — real-time agent state, queue depth
|
||||||
|
3. **Settings/config screen** — view/edit connection settings, model preferences
|
||||||
|
4. **Multiple agent sessions** — split view or tab-based multi-agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
- **Ink 5** (React for CLI) — already in deps
|
||||||
|
- **Component architecture** — break monolithic `app.tsx` into composable components
|
||||||
|
- **Typed Socket.IO events** — leverage `@mosaic/types` `ServerToClientEvents` / `ClientToServerEvents`
|
||||||
|
- **Local state only** (Wave 1) — cwd/branch read from `process.cwd()` and `git` at startup
|
||||||
|
- **Gateway metadata** (future) — extend socket handshake or add REST endpoint for model info, token usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals (for now)
|
||||||
|
|
||||||
|
- Image rendering in terminal
|
||||||
|
- File editor integration
|
||||||
|
- SSH/remote gateway auto-discovery
|
||||||
105
docs/TASKS-TUI_Improvements.md
Normal file
105
docs/TASKS-TUI_Improvements.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Tasks: TUI Improvements
|
||||||
|
|
||||||
|
**Branch:** `feat/p7-tui-improvements`
|
||||||
|
**Worktree:** `/home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements`
|
||||||
|
**PRD:** [PRD-TUI_Improvements.md](./PRD-TUI_Improvements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 1 — Status Bar & Polish ✅
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| -------- | ----------------------------------------------------------------------------------------------------- | ------- | ------- |
|
||||||
|
| TUI-001 | Component architecture — split `app.tsx` into `TopBar`, `BottomBar`, `MessageList`, `InputBar`, hooks | ✅ done | 79ff308 |
|
||||||
|
| TUI-002 | Top status bar — branded mosaic icon, version, model, connection indicator | ✅ done | 6c2b01e |
|
||||||
|
| TUI-003 | Bottom status bar — cwd, git branch, token usage, session ID, gateway status | ✅ done | e8d7ab8 |
|
||||||
|
| TUI-004 | Message formatting — timestamps, role colors (❯ you / ◆ assistant), word wrap | ✅ done | 79ff308 |
|
||||||
|
| TUI-005 | Quiet disconnect — single indicator, auto-reconnect, no error flood | ✅ done | 79ff308 |
|
||||||
|
| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 |
|
||||||
|
| TUI-007 | Thinking/reasoning display — dimmed 💭 block for `agent:thinking` events | ✅ done | 79ff308 |
|
||||||
|
| TUI-007b | Wire token usage, model info, thinking levels end-to-end (gateway → types → TUI) | ✅ done | a061a64 |
|
||||||
|
| TUI-007c | Ctrl+T to cycle thinking levels via `set:thinking` socket event | ✅ done | a061a64 |
|
||||||
|
|
||||||
|
## Wave 2 — Layout & Navigation ✅
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | --------------------------------------------------------- | ------- | ------- |
|
||||||
|
| TUI-010 | Scrollable message history — viewport with PgUp/PgDn | ✅ done | 4d4ad38 |
|
||||||
|
| TUI-008 | Conversation sidebar — list, create, switch conversations | ✅ done | 9ef578c |
|
||||||
|
| TUI-009 | Keybinding system — Ctrl+L, Ctrl+N, Ctrl+K, Escape | ✅ done | 9f38f5a |
|
||||||
|
| TUI-011 | Message search — find in current conversation | ✅ done | 8627827 |
|
||||||
|
|
||||||
|
## Wave 3 — Advanced Features
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | ----------------------- | ----------- | ----- |
|
||||||
|
| TUI-012 | Project/mission views | not-started | |
|
||||||
|
| TUI-013 | Agent status monitoring | not-started | |
|
||||||
|
| TUI-014 | Settings/config screen | not-started | |
|
||||||
|
| TUI-015 | Multiple agent sessions | not-started | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handoff Notes
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/cli/src/tui/
|
||||||
|
├── app.tsx ← Shell composing all components + global keybindings
|
||||||
|
├── components/
|
||||||
|
│ ├── top-bar.tsx ← Mosaic icon + version + model + connection
|
||||||
|
│ ├── bottom-bar.tsx ← Keybinding hints + 3-line footer: gateway, cwd, tokens
|
||||||
|
│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming, search highlights
|
||||||
|
│ ├── input-bar.tsx ← Bordered prompt with context-aware placeholder
|
||||||
|
│ ├── sidebar.tsx ← Conversation list with keyboard navigation
|
||||||
|
│ └── search-bar.tsx ← Message search input with match count + navigation
|
||||||
|
└── hooks/
|
||||||
|
├── use-socket.ts ← Typed Socket.IO + switchConversation/clearMessages
|
||||||
|
├── use-git-info.ts ← Reads cwd + git branch at startup
|
||||||
|
├── use-viewport.ts ← Scrollable viewport with auto-follow + PgUp/PgDn
|
||||||
|
├── use-app-mode.ts ← Panel focus state machine (chat/sidebar/search)
|
||||||
|
├── use-conversations.ts ← REST client for conversation CRUD
|
||||||
|
└── use-search.ts ← Message search with match cycling
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Package Changes
|
||||||
|
|
||||||
|
- **`packages/types/src/chat/events.ts`** — Added `SessionUsagePayload`, `SessionInfoPayload`, `SetThinkingPayload`, `session:info` event, `set:thinking` event
|
||||||
|
- **`apps/gateway/src/chat/chat.gateway.ts`** — Emits `session:info` on session creation, includes `usage` in `agent:end`, handles `set:thinking`
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
#### Wave 1
|
||||||
|
|
||||||
|
- Footer is 3 lines: (1) gateway status right-aligned, (2) cwd+branch left / session right, (3) tokens left / provider+model+thinking right
|
||||||
|
- Mosaic icon uses brand colors in windmill cross pattern with `GAP` const to prevent prettier collapsing spaces
|
||||||
|
- `flexGrow={1}` on header text column prevents re-render artifacts
|
||||||
|
- Token/model data comes from gateway via `agent:end` payload and `session:info` events
|
||||||
|
- Thinking level cycling via Ctrl+T sends `set:thinking` to gateway, which validates and responds with `session:info`
|
||||||
|
|
||||||
|
#### Wave 2
|
||||||
|
|
||||||
|
- `useViewport` calculates scroll offset from terminal rows; auto-follow snaps to bottom on new messages
|
||||||
|
- `useAppMode` state machine manages focus: only the active panel handles keyboard input via `useInput({ isActive })`
|
||||||
|
- Sidebar fetches conversations via REST (`GET /api/conversations`), not socket events
|
||||||
|
- `switchConversation` in `useSocket` clears all local state (messages, streaming, tool calls)
|
||||||
|
- Search uses `useMemo` for reactive match computation; viewport auto-scrolls to current match
|
||||||
|
- Keybinding hints shown in bottom bar: `^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll`
|
||||||
|
|
||||||
|
### How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
|
||||||
|
pnpm --filter @mosaic/cli exec tsx src/cli.ts tui
|
||||||
|
# or after build:
|
||||||
|
node packages/cli/dist/cli.js tui --gateway http://localhost:4000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint
|
||||||
|
pnpm --filter @mosaic/gateway typecheck && pnpm --filter @mosaic/gateway lint
|
||||||
|
pnpm --filter @mosaic/types typecheck
|
||||||
|
```
|
||||||
169
docs/TASKS.md
169
docs/TASKS.md
@@ -2,80 +2,95 @@
|
|||||||
|
|
||||||
> 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 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
|
||||||
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
|
||||||
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
|
||||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
|
||||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
| P8-009 | done | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | #176 | #162 |
|
||||||
|
| P8-010 | done | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | #178 | #163 |
|
||||||
|
| P8-011 | done | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | #180 | #164 |
|
||||||
|
| P8-012 | done | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | #181 | #165 |
|
||||||
|
| P8-013 | done | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | #182 | #166 |
|
||||||
|
| P8-014 | done | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | #179 | #167 |
|
||||||
|
| P8-015 | done | Phase 8 | Gateway Phase 7 — WorkspaceService, ProjectBootstrapService, teams project ownership | #183 | #168 |
|
||||||
|
| P8-016 | done | Phase 8 | Security — file/git/shell tool strict path hardening, sandbox escape prevention | #177 | #169 |
|
||||||
|
| P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 |
|
||||||
|
| P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
|
||||||
|
| P8-019 | done | Phase 8 | Verify Platform Architecture — integration + E2E verification | #185 | #172 |
|
||||||
|
| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||||
|
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||||
|
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
||||||
|
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||||
|
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||||
|
|||||||
1572
docs/plans/2026-03-15-agent-platform-architecture.md
Normal file
1572
docs/plans/2026-03-15-agent-platform-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
File diff suppressed because it is too large
Load Diff
60
docs/plans/chroot-sandboxing.md
Normal file
60
docs/plans/chroot-sandboxing.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Chroot Agent Sandboxing — Process Isolation for Agent Tool Execution
|
||||||
|
|
||||||
|
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces → Chroot Agent Sandboxing).
|
||||||
|
> Implement after Workspaces (P8-015) is complete. Requires workspace directory structure and `WorkspaceService` to be operational.
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Packages:** `apps/gateway`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Agent sessions can use file, git, and shell tools. Path validation in tools is defense-in-depth but insufficient alone — an agent with shell access can run `cat /opt/mosaic/.workspaces/other_user/...` and bypass gateway RBAC.
|
||||||
|
|
||||||
|
Chroot provides OS-level enforcement: tool processes literally cannot see outside their workspace directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design (Sweet Spot)
|
||||||
|
|
||||||
|
Chroot strikes the balance between full container isolation (too heavy per session) and path validation only (escape-prone):
|
||||||
|
|
||||||
|
- Gateway spawns tool processes inside a chroot rooted at the session's `sandboxDir`
|
||||||
|
- Requires `CAP_SYS_CHROOT` capability on the gateway process (not full root)
|
||||||
|
- Chroot environment provisioned by `WorkspaceService` on workspace creation (minimal deps: git, shell utils, language runtimes as needed)
|
||||||
|
- Alternative for Docker deployments: Linux `unshare` namespaces (lighter, no chroot env setup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope (To Be Designed)
|
||||||
|
|
||||||
|
- [ ] Chroot environment provisioning — `WorkspaceService.provisionChroot(workspacePath)` on project creation
|
||||||
|
- [ ] Minimal chroot deps — identify required binaries/libs per tool type (file: none; git: git binary; shell: bash, common utils)
|
||||||
|
- [ ] Gateway capability — document `CAP_SYS_CHROOT` requirement; Dockerfile and docker-compose.yml changes
|
||||||
|
- [ ] Tool process spawning — modify `createShellTools`, `createFileTools`, `createGitTools` to spawn via chroot wrapper
|
||||||
|
- [ ] Docker alternative — `unshare --mount --pid --user` namespace wrapper as fallback for environments without chroot capability
|
||||||
|
- [ ] Defense-in-depth layering — chroot + path validation both active; neither alone is sufficient
|
||||||
|
- [ ] Chroot cleanup — integrate with `SessionGCService` / workspace deletion
|
||||||
|
- [ ] AppArmor/SELinux profiles (v2) — restrict gateway process file access patterns for multi-tenant hardening
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Constraints
|
||||||
|
|
||||||
|
- What lives **inside** the chroot (agent-accessible): workspace files, git repo, language runtimes
|
||||||
|
- What lives **outside** the chroot (gateway-only, never agent-accessible): Valkey connection, PG connection, other users' workspaces, gateway config, OTEL endpoint, credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Workspaces (P8-015) — chroot is rooted at workspace directory; workspace must exist first
|
||||||
|
- Tool hardening (P8-016) — path validation stays active as defense-in-depth alongside chroot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Chroot Agent Sandboxing" section
|
||||||
|
- Current tool implementations: `apps/gateway/src/agent/tools/`
|
||||||
53
docs/plans/gatekeeper-service.md
Normal file
53
docs/plans/gatekeeper-service.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Gatekeeper Service — PR Review, Quality Gates & Merge Authority
|
||||||
|
|
||||||
|
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Phase 7 Workspaces).
|
||||||
|
> Implement after Workspaces (P8-015) is complete and the workspace/git infrastructure is operational.
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Packages:** `apps/gateway`, `packages/types`, `packages/agent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Project agents create PRs but cannot review or merge their own work. A separate, isolated agent service with read-only code access and quality gate enforcement is needed to act as the authoritative merge authority.
|
||||||
|
|
||||||
|
The Gatekeeper existed in the old Mosaic codebase and must be ported/redesigned for mosaic-mono-v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Constraints
|
||||||
|
|
||||||
|
- **Isolated trust boundary** — project agents cannot invoke Gatekeeper directly; it listens for PR events from the git provider
|
||||||
|
- **`isSystem: true`** — system agent, not editable by users
|
||||||
|
- **Read-only code access** — reads diffs and runs checks; cannot commit or push
|
||||||
|
- **Quality gates required before merge** — lint, typecheck, test results must pass
|
||||||
|
- **Cannot self-approve** — the agent that authored the PR cannot be the Gatekeeper for that PR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope (To Be Designed)
|
||||||
|
|
||||||
|
- [ ] Gatekeeper agent bootstrap — system agent config, tool set, prompt engineering
|
||||||
|
- [ ] PR event listener — Gitea/GitHub webhook integration (PR opened/updated/ready)
|
||||||
|
- [ ] Quality gate runner — trigger CI checks, poll for results, enforce pass criteria
|
||||||
|
- [ ] Review generation — LLM-driven code review comment generation
|
||||||
|
- [ ] Merge execution — approve + merge when gates pass; reject with comments when they fail
|
||||||
|
- [ ] Configurable strictness — per-project required checks, review depth
|
||||||
|
- [ ] Trust boundary enforcement — gateway rejects Gatekeeper tool calls that exceed read-only scope
|
||||||
|
- [ ] Audit trail — OTEL spans for all Gatekeeper decisions (approve/reject/merge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Workspaces (P8-015) — Gatekeeper needs project workspace layout to locate code
|
||||||
|
- Git provider API tools — PR creation/review/merge API (Gitea/GitHub/GitLab)
|
||||||
|
- CI/CD tool integration — Woodpecker pipeline status polling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Gatekeeper Service" section
|
||||||
|
- Workspace RBAC and agent trust model: same document → "RBAC & Filesystem Security"
|
||||||
60
docs/plans/task-queue-unification.md
Normal file
60
docs/plans/task-queue-unification.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Task Queue Unification — @mosaic/queue as Unified Orchestration Layer
|
||||||
|
|
||||||
|
> **Status:** Stub — deferred. Referenced from `2026-03-15-agent-platform-architecture.md` (Task Queue & Orchestration section).
|
||||||
|
> Implement after Workspaces (P8-015) is complete. Requires workspace file structure to be in place.
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Packages:** `packages/queue`, `packages/coord`, `packages/db`, `apps/gateway`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Two disconnected task systems exist:
|
||||||
|
|
||||||
|
1. **`@mosaic/coord`** — file-based missions (`mission.json`, `TASKS.md`), file locks, subprocess spawning. Single-machine orchestrator pattern.
|
||||||
|
2. **PG tables** (`tasks`, `mission_tasks`, `missions`) — DB-backed CRUD, REST API, Brain repos.
|
||||||
|
|
||||||
|
An agent using `coord_mission_status` gets file data. The dashboard shows DB data. They are never in sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
`@mosaic/queue` becomes the unified task orchestration service bridging PG, workspace files, and Valkey:
|
||||||
|
|
||||||
|
- DB is source of truth for structured state (status, assignees, timestamps)
|
||||||
|
- Workspace files (`TASKS.md`, PRDs) are working copies for agent interaction
|
||||||
|
- Valkey handles real-time assignment queues and agent claim locks
|
||||||
|
- Flatfile fallback for no-DB single-machine deployments (preserves `@mosaic/coord` pattern)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope (To Be Designed)
|
||||||
|
|
||||||
|
- [ ] `@mosaic/queue` refactor — elevate from ioredis primitive to task orchestration service
|
||||||
|
- [ ] DB ↔ file sync layer — writes to PG propagate to `TASKS.md`; file edits by agents sync back
|
||||||
|
- [ ] Task assignment queue — Valkey-backed RPUSH/BLPOP for agent task claiming
|
||||||
|
- [ ] Agent claim locks — `mosaic:queue:project:{id}:lock:{taskId}` with TTL
|
||||||
|
- [ ] `@mosaic/coord` consolidation — file-based ops ported into queue service; `@mosaic/coord` becomes thin adapter or deprecated
|
||||||
|
- [ ] Flatfile fallback — queue service writes JSON manifests when PG unavailable
|
||||||
|
- [ ] Status pub/sub — real-time task status updates via Valkey pub/sub
|
||||||
|
- [ ] Dependency resolution — block task assignment until dependencies are met
|
||||||
|
- [ ] Orchestrator monitor — gateway process watches task queue, assigns next based on dependency graph
|
||||||
|
- [ ] API surface — queue service exposes typed interface used by agents, gateway, and CLI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Workspaces (P8-015) — file sync targets the workspace directory structure
|
||||||
|
- Teams architecture (P8-007) — project ownership determines queue namespacing
|
||||||
|
- DB schema stable — task/mission tables must not change mid-unification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Original design context: `docs/plans/2026-03-15-agent-platform-architecture.md` → "Task Queue & Orchestration" section
|
||||||
|
- Current `@mosaic/coord` implementation: `packages/coord/src/`
|
||||||
|
- Current `@mosaic/queue` implementation: `packages/queue/src/`
|
||||||
47
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
47
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# BUG-CLI Scratchpad
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
- #192: Ctrl+T leaks 't' into input
|
||||||
|
- #193: Duplicate React keys in CommandAutocomplete
|
||||||
|
- #194: /provider login false clipboard claim
|
||||||
|
- #199: TUI shows hardcoded version "0.0.0"
|
||||||
|
|
||||||
|
## Plan and Fixes
|
||||||
|
|
||||||
|
### Bug #192 — Ctrl+T character leak
|
||||||
|
|
||||||
|
- Location: `packages/cli/src/tui/app.tsx`
|
||||||
|
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
||||||
|
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||||
|
leaked character and return early.
|
||||||
|
|
||||||
|
### Bug #193 — Duplicate React keys
|
||||||
|
|
||||||
|
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||||
|
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
||||||
|
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||||
|
that share a name with local commands. Local commands take precedence.
|
||||||
|
|
||||||
|
### Bug #194 — False clipboard claim
|
||||||
|
|
||||||
|
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||||
|
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||||
|
|
||||||
|
### Bug #199 — Hardcoded version "0.0.0"
|
||||||
|
|
||||||
|
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
||||||
|
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
||||||
|
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
||||||
|
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- CLI typecheck: PASSED
|
||||||
|
- CLI lint: PASSED
|
||||||
|
- Prettier format:check: PASSED
|
||||||
|
- Gateway lint: PASSED
|
||||||
37
docs/scratchpads/bug-196-admin-redirect.md
Normal file
37
docs/scratchpads/bug-196-admin-redirect.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# BUG-196: Admin Page Redirect Issue
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Admin page redirects to /chat for users with admin role because role check fails.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The `role` field is defined as an `additionalField` in better-auth's user configuration, but
|
||||||
|
better-auth v1.5.5 does not automatically include additionalFields in the session response from
|
||||||
|
the `getSession()` API. This causes the admin role check to fail:
|
||||||
|
|
||||||
|
- Frontend: `AdminRoleGuard` checks `user?.role !== 'admin'`
|
||||||
|
- Backend: `AdminGuard` checks `user.role !== 'admin'`
|
||||||
|
- When `role` is `undefined`, both checks treat the user as non-admin and deny access
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Implemented a defensive check in the backend `AdminGuard` that:
|
||||||
|
|
||||||
|
1. First tries to use the `role` field from the session (if better-auth includes it)
|
||||||
|
2. Falls back to fetching the role directly from the database if it's missing
|
||||||
|
3. Defaults to 'member' if the user has no role set
|
||||||
|
|
||||||
|
This ensures that admin users can always access the admin panel, and also protects against
|
||||||
|
the case where better-auth doesn't include the additionalField in future versions.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
1. `/apps/gateway/src/admin/admin.guard.ts` - Added fallback role lookup
|
||||||
|
2. `/packages/auth/src/auth.ts` - No changes needed (better-auth config is correct)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- All three quality gates pass: `typecheck`, `lint`, `format:check`
|
||||||
|
- Backend admin guard now explicitly handles missing role field
|
||||||
|
- Frontend admin guard remains unchanged (will work once role is available)
|
||||||
@@ -222,3 +222,47 @@ Issues closed: #52, #55, #57, #58, #120-#134
|
|||||||
- Infrastructure: coord DB migration, agent sandbox hardening
|
- Infrastructure: coord DB migration, agent sandbox hardening
|
||||||
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
- Quality: E2E Playwright suite (~35 tests), comprehensive docs (user/admin/dev/deployment)
|
||||||
- Fixes: TUI state updater, agent session sandboxing
|
- Fixes: TUI state updater, agent session sandboxing
|
||||||
|
|
||||||
|
### Session 13 — CLI Command Architecture (P8-005, P8-006)
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 13 | 2026-03-15 | Phase 8 | P8-005, P8-006 | CLI command architecture implemented. DB schema, brain repo, gateway endpoints, CLI commands. PR #158 merged. |
|
||||||
|
|
||||||
|
**Changes delivered:**
|
||||||
|
|
||||||
|
- DB: Extended agents table (projectId, ownerId, systemPrompt, allowedTools, skills, isSystem). Added agentId to conversations.
|
||||||
|
- Brain: New agents repository with findAccessible (owner's + system agents).
|
||||||
|
- Gateway: /api/agents CRUD, consolidated /api/missions with user-scoped CRUD + /tasks sub-routes, coord slimmed to file-based only, agentConfigId wired into session creation.
|
||||||
|
- CLI: `mosaic agent` (--list, --new, --show, --update, --delete), `mosaic mission` (--list, --init, --plan, --update, task subcommand), `mosaic prdy` (gateway-aware), shared with-auth + select-dialog utilities.
|
||||||
|
- TUI: --agent and --project flags, agent name display in top bar, agentId in socket payload.
|
||||||
|
- Types: agentId added to ChatMessagePayload.
|
||||||
|
- Tests: 23/23 gateway tests pass (updated ownership test for user-scoped missions).
|
||||||
|
|
||||||
|
### Session 14 — Platform Architecture Plan Augmentation + Task Breakdown
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------------- |
|
||||||
|
| 14 | 2026-03-15 | Phase 8 | P8-018 | Augmented plan, created 13 issues, created Phase 8 milestone. |
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
|
||||||
|
- This plan is Phase 7 feature extension work, not Phase 8 beta scope. P8-001–P8-004 (SSO, LLM, perf, release gate) are deferred to far future.
|
||||||
|
- `/provider` OAuth in TUI: URL-to-clipboard + Valkey poll token pattern (same as Pi agent)
|
||||||
|
- Add `mutable` column to preferences now (P8-007 DB migration)
|
||||||
|
- Teams architecture: `teams` + `team_members` tables, `teamId`/`ownerType` on projects. Workspace path branches on owner type: `users/<uid>/` vs `teams/<tid>/`.
|
||||||
|
- Phase dependency chain decided: Wave 1 (DB+Types) → Wave 2 (TUI+toolhardening) → Wave 3 (gateway registry, gating) → Wave 4 (prefs+commands) → Wave 5 (reload+GC) → Wave 6 (workspaces) → Wave 7 (autocomplete) → Wave 8 (verify).
|
||||||
|
|
||||||
|
**Plan augmentations added:**
|
||||||
|
|
||||||
|
- Teams Architecture section (DB schema, workspace paths, RBAC)
|
||||||
|
- REST Route Specifications table
|
||||||
|
- `/provider` OAuth flow (URL+clipboard+polling)
|
||||||
|
- Preferences `mutable` migration spec
|
||||||
|
- Test Strategy (per-task test files + key test cases)
|
||||||
|
- Phase Execution Order (dependency graph + wave plan)
|
||||||
|
|
||||||
|
**Issues created:** #160–#172 (Gitea milestone ms-165)
|
||||||
|
**P8-018 closed:** Spin-off stubs created (gatekeeper-service.md, task-queue-unification.md, chroot-sandboxing.md)
|
||||||
|
|
||||||
|
**Next:** Begin execution at Wave 1 — P8-007 (DB migrations) + P8-008 (Types) in parallel.
|
||||||
|
|||||||
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# P8-001 — WorkOS + Keycloak SSO Providers
|
||||||
|
|
||||||
|
**Branch:** feat/p8-001-sso-providers
|
||||||
|
**Started:** 2026-03-18
|
||||||
|
**Mode:** Delivery
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add WorkOS and Keycloak as optional SSO providers to the BetterAuth configuration, following the existing Authentik pattern.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `packages/auth/src/auth.ts` | Refactor provider array, add WorkOS + Keycloak conditional registration |
|
||||||
|
| `apps/web/src/lib/auth-client.ts` | Add `genericOAuthClient()` plugin |
|
||||||
|
| `apps/web/src/app/(auth)/login/page.tsx` | WorkOS + Keycloak SSO buttons gated by `NEXT_PUBLIC_*` env vars |
|
||||||
|
| `.env.example` | Document WorkOS + Keycloak env vars |
|
||||||
|
| `packages/auth/src/auth.test.ts` | Unit tests verifying env-var gating |
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. ✅ Refactor `createAuth` to build `oauthProviders[]` conditionally
|
||||||
|
2. ✅ Add WorkOS provider (explicit URLs, no discovery)
|
||||||
|
3. ✅ Add Keycloak provider (discoveryUrl pattern)
|
||||||
|
4. ✅ Add `genericOAuthClient()` to auth-client.ts
|
||||||
|
5. ✅ Add SSO buttons to login page gated by `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED`
|
||||||
|
6. ✅ Update `.env.example`
|
||||||
|
7. ⏳ Write `auth.test.ts` with env-var gating tests
|
||||||
|
8. ⏳ Quality gates: typecheck + lint + format:check + test
|
||||||
|
9. ⏳ Commit + push + PR
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **WorkOS**: Uses explicit `authorizationUrl`, `tokenUrl`, `userInfoUrl` (no discovery endpoint available)
|
||||||
|
- **Keycloak**: Uses `discoveryUrl` pattern (`{URL}/realms/{REALM}/.well-known/openid-configuration`)
|
||||||
|
- **UI gating**: Login page uses `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED` feature flags (safer than exposing secret env var names client-side)
|
||||||
|
- **Refactor**: Authentik moved into same `oauthProviders[]` array pattern — cleaner, more extensible
|
||||||
|
- **Feature flag design**: `NEXT_PUBLIC_*` flags are opt-in alongside credentials (prevents accidental button render when creds not set)
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `ASSUMPTION:` WorkOS OIDC discovery URL is not publicly documented; using direct URL pattern from WorkOS SSO docs.
|
||||||
|
- `ASSUMPTION:` `NEXT_PUBLIC_WORKOS_ENABLED=true` must be explicitly set — this is intentional (credential presence alone doesn't enable the button since NEXT_PUBLIC vars are baked at build time).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `auth.test.ts`: Mocks betterAuth stack, verifies WorkOS included/excluded based on env var
|
||||||
|
- `auth.test.ts`: Verifies Keycloak discoveryUrl constructed correctly
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Status |
|
||||||
|
| ------------------- | -------------------------------------------- |
|
||||||
|
| typecheck | ✅ 32/32 cached green |
|
||||||
|
| lint | ✅ 18/18 cached green |
|
||||||
|
| format:check | ✅ All matched files use Prettier code style |
|
||||||
|
| test (@mosaic/auth) | ✅ 8/8 tests passed |
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- `pnpm typecheck` — FULL TURBO, 32 tasks successful
|
||||||
|
- `pnpm lint` — FULL TURBO, 18 tasks successful
|
||||||
|
- `pnpm format:check` — All matched files use Prettier code style!
|
||||||
|
- `pnpm --filter=@mosaic/auth test` — 8 tests passed, 0 failed
|
||||||
40
docs/scratchpads/p8-009-tui-slash-commands.md
Normal file
40
docs/scratchpads/p8-009-tui-slash-commands.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# P8-009: TUI Phase 1 — Slash Command Parsing
|
||||||
|
|
||||||
|
## Task Reference
|
||||||
|
|
||||||
|
- Issue: #162
|
||||||
|
- Branch: feat/p8-009-tui-slash-commands
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- New files: parse.ts, registry.ts, local/help.ts, local/status.ts, commands/index.ts
|
||||||
|
- Modified files: use-socket.ts, input-bar.tsx, message-list.tsx, app.tsx
|
||||||
|
|
||||||
|
## Key Observations
|
||||||
|
|
||||||
|
- CommandDef in @mosaic/types does NOT have `category` field — will omit from LOCAL_COMMANDS
|
||||||
|
- CommandDef.args is `CommandArgDef[] | undefined`, not `{ usage: string }` — help.ts args rendering needs adjustment
|
||||||
|
- Message role union currently: 'user' | 'assistant' | 'thinking' | 'tool' — adding 'system'
|
||||||
|
- InputBar currently takes `onSubmit: (value: string) => void` — need to add slash command interception
|
||||||
|
- app.tsx passes `onSubmit={socket.sendMessage}` directly — needs command-aware handler
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMPTION: `category` field not in CommandDef type — will skip category grouping in help output, or add it only to registry (not to CommandDef type)
|
||||||
|
- ASSUMPTION: For the `args` field display in help, will use `CommandArgDef.name` and `CommandArgDef.description`
|
||||||
|
- ASSUMPTION: `commands:manifest` event type may not be in ServerToClientEvents — will handle via socket.on with casting if needed
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [ ] Create commands directory structure
|
||||||
|
- [ ] Implement parse.ts
|
||||||
|
- [ ] Implement registry.ts
|
||||||
|
- [ ] Implement local/help.ts
|
||||||
|
- [ ] Implement local/status.ts
|
||||||
|
- [ ] Implement commands/index.ts
|
||||||
|
- [ ] Modify use-socket.ts
|
||||||
|
- [ ] Modify input-bar.tsx
|
||||||
|
- [ ] Modify message-list.tsx
|
||||||
|
- [ ] Modify app.tsx
|
||||||
|
- [ ] Run quality gates
|
||||||
|
- [ ] Commit + Push + PR + CI
|
||||||
72
docs/scratchpads/p8-010-command-registry.md
Normal file
72
docs/scratchpads/p8-010-command-registry.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# P8-010 Scratchpad — Gateway Phase 2: CommandRegistryService + CommandExecutorService
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement gateway-side command registry system:
|
||||||
|
|
||||||
|
- `CommandRegistryService` — owns canonical command manifest, broadcasts on connect
|
||||||
|
- `CommandExecutorService` — routes `command:execute` socket events
|
||||||
|
- `CommandsModule` — NestJS wiring
|
||||||
|
- Wire into `ChatGateway` and `AppModule`
|
||||||
|
- Register core commands
|
||||||
|
- Tests for CommandRegistryService
|
||||||
|
|
||||||
|
## Key Findings from Codebase
|
||||||
|
|
||||||
|
### CommandDef shape (from packages/types/src/commands/index.ts)
|
||||||
|
|
||||||
|
- `scope: 'core' | 'agent' | 'skill' | 'plugin' | 'admin'` (NOT `category`)
|
||||||
|
- `args?: CommandArgDef[]` — array of arg defs, each with `name`, `type`, `optional`, `values?`, `description?`
|
||||||
|
- No `aliases` required (it's listed but optional-ish... wait, it IS in the interface)
|
||||||
|
- `aliases: string[]` — IS present
|
||||||
|
|
||||||
|
### SlashCommandResultPayload requires `conversationId`
|
||||||
|
|
||||||
|
- The task spec shows `{ command, success, error }` without `conversationId` but actual type requires it
|
||||||
|
- Must include `conversationId` in all return values
|
||||||
|
|
||||||
|
### CommandManifest has `skills: SkillCommandDef[]`
|
||||||
|
|
||||||
|
- Must include `skills` array in manifest
|
||||||
|
|
||||||
|
### userId extraction in ChatGateway
|
||||||
|
|
||||||
|
- `client.data.user` holds the user object (set in `handleConnection`)
|
||||||
|
- `client.data.user.id` or similar for userId
|
||||||
|
|
||||||
|
### AgentModule not imported in ChatModule
|
||||||
|
|
||||||
|
- ChatGateway imports AgentService via DI
|
||||||
|
- ChatModule doesn't declare imports — AgentModule must be global or imported
|
||||||
|
|
||||||
|
### Worktree branch
|
||||||
|
|
||||||
|
- Branch: `feat/p8-010-command-registry`
|
||||||
|
- Working in: `/home/jwoltje/src/mosaic-mono-v1/.claude/worktrees/agent-ac85b3b2`
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Create `apps/gateway/src/commands/command-registry.service.ts`
|
||||||
|
2. Create `apps/gateway/src/commands/command-executor.service.ts`
|
||||||
|
3. Create `apps/gateway/src/commands/commands.module.ts`
|
||||||
|
4. Modify `apps/gateway/src/app.module.ts` — add CommandsModule
|
||||||
|
5. Modify `apps/gateway/src/chat/chat.module.ts` — import CommandsModule
|
||||||
|
6. Modify `apps/gateway/src/chat/chat.gateway.ts` — inject services, add handler, emit manifest
|
||||||
|
7. Create `apps/gateway/src/commands/command-registry.service.spec.ts`
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [ ] Create CommandRegistryService
|
||||||
|
- [ ] Create CommandExecutorService
|
||||||
|
- [ ] Create CommandsModule
|
||||||
|
- [ ] Update AppModule
|
||||||
|
- [ ] Update ChatModule
|
||||||
|
- [ ] Update ChatGateway
|
||||||
|
- [ ] Write tests
|
||||||
|
- [ ] Run quality gates
|
||||||
|
- [ ] Commit + push + PR
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- CommandDef `args` shape mismatch from task spec — must use actual type
|
||||||
|
- `SlashCommandResultPayload.conversationId` is required — handle missing conversationId
|
||||||
44
docs/scratchpads/p8-012-agent-provider-commands.md
Normal file
44
docs/scratchpads/p8-012-agent-provider-commands.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# P8-012 Scratchpad — Gateway /agent, /provider, /mission, /prdy, /tools Commands
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add gateway-executed commands: `/agent`, `/provider`, `/mission`, `/prdy`, `/tools`.
|
||||||
|
Key feature: `/provider login` OAuth flow with Valkey poll token.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Read all relevant files (done)
|
||||||
|
2. Update `command-registry.service.ts` — add 5 new command registrations
|
||||||
|
3. Update `commands.module.ts` — wire Redis injection for executor
|
||||||
|
4. Update `command-executor.service.ts` — add 5 new command handlers + Redis injection
|
||||||
|
5. Write spec file for new commands
|
||||||
|
6. Run quality gates (typecheck, lint, format:check, test)
|
||||||
|
7. Commit and push
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- Redis pattern: same as GCModule — use `REDIS` token injected from a QueueHandle factory
|
||||||
|
- `CommandDef` type fields: `scope: 'core'|'agent'|'skill'|'plugin'|'admin'`, `args?: CommandArgDef[]`, `execution: 'local'|'socket'|'rest'|'hybrid'`
|
||||||
|
- No `category` or `usage` fields — instruction spec was wrong on that
|
||||||
|
- `SlashCommandResultPayload.conversationId` is typed as `string` (not `string | undefined`) per the type
|
||||||
|
- Provider commands are `scope: 'agent'` since they relate to agent configuration
|
||||||
|
- Redis injection: add a `COMMANDS_REDIS` token in commands module, inject via factory pattern same as GCModule
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [ ] command-registry.service.ts updated
|
||||||
|
- [ ] commands.module.ts updated (add Redis provider)
|
||||||
|
- [ ] command-executor.service.ts updated (add Redis injection + handlers)
|
||||||
|
- [ ] spec file written
|
||||||
|
- [ ] quality gates pass
|
||||||
|
- [ ] commit + push + PR
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- `conversationId` typing: `SlashCommandResultPayload.conversationId` is `string`, but some handler calls pass `undefined`. Need to check if it's optional.
|
||||||
|
|
||||||
|
After reviewing types: `conversationId: string` in `SlashCommandResultPayload` — not optional. Must pass empty string or actual ID. Looking at existing code: `message: 'Start a new conversation...'` returns `{ command, conversationId, ... }` where conversationId comes from payload which is always a string per `SlashCommandPayload`. For provider commands that don't have a conversationId, pass empty string `''` or the payload's conversationId.
|
||||||
|
|
||||||
|
Actually looking at the spec more carefully: `handleProvider` returns `conversationId: undefined`. But the type says `string`. This would be a TypeScript error. I'll use `''` as a fallback or adjust. Let me re-examine...
|
||||||
|
|
||||||
|
The `SlashCommandResultPayload` interface says `conversationId: string` — not optional. But the spec says `conversationId: undefined`. I'll use `payload.conversationId` (passing it through) since it comes from the payload.
|
||||||
55
docs/scratchpads/p8-016-tool-hardening.md
Normal file
55
docs/scratchpads/p8-016-tool-hardening.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# P8-016: Security — Tool Path Hardening + Sandbox Escape Prevention
|
||||||
|
|
||||||
|
## Status: in-progress
|
||||||
|
|
||||||
|
## Branch: feat/p8-016-tool-hardening
|
||||||
|
|
||||||
|
## Issue: #169
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Harden file, git, and shell tool factories so no path operation escapes `sandboxDir`.
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
- `apps/gateway/src/agent/tools/path-guard.ts` (new)
|
||||||
|
- `apps/gateway/src/agent/tools/path-guard.test.ts` (new)
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
- `apps/gateway/src/agent/tools/file-tools.ts`
|
||||||
|
- `apps/gateway/src/agent/tools/git-tools.ts`
|
||||||
|
- `apps/gateway/src/agent/tools/shell-tools.ts`
|
||||||
|
|
||||||
|
## Analysis
|
||||||
|
|
||||||
|
### file-tools.ts
|
||||||
|
|
||||||
|
- Has existing `resolveSafe()` function but uses weak containment check (relative path)
|
||||||
|
- Replace with `guardPath` (for reads/lists on existing paths) and `guardPathUnsafe` (for writes)
|
||||||
|
- Error pattern: return `{ content: [{ type: 'text', text: 'Error: ...' }], details: undefined }`
|
||||||
|
|
||||||
|
### git-tools.ts
|
||||||
|
|
||||||
|
- Has `clampCwd()` that silently falls back to sandbox root on escape attempt
|
||||||
|
- Replace with strict `guardPath` that throws SandboxEscapeError, caught and returned as error
|
||||||
|
- Also need to guard the `path` parameter in `git_diff`
|
||||||
|
|
||||||
|
### shell-tools.ts
|
||||||
|
|
||||||
|
- Has `clampCwd()` same silent-fallback approach
|
||||||
|
- Replace with strict `guardPath` that throws SandboxEscapeError
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- `guardPath`: uses `realpathSync.native` to resolve symlinks, requires path to exist
|
||||||
|
- `guardPathUnsafe`: lexical only (`path.resolve`), for paths that may not exist yet
|
||||||
|
- Both throw `SandboxEscapeError` on escape attempt
|
||||||
|
- Callers catch and return error result
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- pnpm typecheck
|
||||||
|
- pnpm lint
|
||||||
|
- pnpm format:check
|
||||||
|
- pnpm test
|
||||||
103
docs/scratchpads/p8-019-verify.md
Normal file
103
docs/scratchpads/p8-019-verify.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# P8-019 Verification — Phase 8 Platform Architecture
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Status:** complete
|
||||||
|
**Branch:** feat/p8-019-verify
|
||||||
|
**PR:** #185
|
||||||
|
**Issue:** #172
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- Unit tests (baseline, pre-P8-019): 101 passing across 9 gateway test files + 1 CLI file
|
||||||
|
- Integration tests added: 2 new spec files (68 new tests)
|
||||||
|
- `apps/gateway/src/commands/commands.integration.spec.ts` — 42 tests
|
||||||
|
- `packages/cli/src/tui/commands/commands.integration.spec.ts` — 26 tests
|
||||||
|
- Total after P8-019: 160 passing tests across 12 test files
|
||||||
|
- Quality gates: typecheck ✓ lint ✓ format:check ✓ test ✓
|
||||||
|
|
||||||
|
## Components Verified
|
||||||
|
|
||||||
|
### Command System
|
||||||
|
|
||||||
|
- `CommandRegistryService.getManifest()` returns 19 core commands (>= 12 requirement met)
|
||||||
|
- All commands have correct `execution` type:
|
||||||
|
- `socket`: model, thinking, new, clear, compact, retry, system, gc, agent, mission, prdy, tools, reload
|
||||||
|
- `rest`: rename, history, export, preferences
|
||||||
|
- `hybrid`: provider, status (gateway), (status overridden to local in TUI)
|
||||||
|
- `local`: help (gateway); help, stop, cost, status, clear (TUI local)
|
||||||
|
- All aliases verified: m→model, t→thinking, n→new, a→agent, s→status, h→help, pref→preferences
|
||||||
|
- `parseSlashCommand()` correctly extracts command + args for all forms
|
||||||
|
- Unknown commands return `success: false` with descriptive message
|
||||||
|
|
||||||
|
### Preferences + System Override
|
||||||
|
|
||||||
|
- `PreferencesService.getEffective()` applies platform defaults when no user overrides
|
||||||
|
- Immutable keys (`limits.maxThinkingLevel`, `limits.rateLimit`) cannot be overridden — enforcement always wins
|
||||||
|
- `set()` returns error for immutable keys with "platform enforcement" message
|
||||||
|
- `SystemOverrideService.set()` stores to Valkey with 5-minute TTL; verified via mock
|
||||||
|
- `/system` command calls `SystemOverrideService.set()` with exact text arg
|
||||||
|
- `/system` with no args calls `SystemOverrideService.clear()`
|
||||||
|
|
||||||
|
### Session GC
|
||||||
|
|
||||||
|
- `collect(sessionId)` deletes all `mosaic:session:<id>:*` Valkey keys
|
||||||
|
- `fullCollect()` clears all `mosaic:session:*` keys on cold start
|
||||||
|
- `sweepOrphans()` extracts unique session IDs from keys and collects each
|
||||||
|
- GC result includes `duration` and `orphanedSessions` count
|
||||||
|
- `/gc` command invokes `sweepOrphans(userId)` and returns count in response
|
||||||
|
|
||||||
|
### Tool Security (path-guard)
|
||||||
|
|
||||||
|
- `guardPath` rejects `../` traversal → throws `SandboxEscapeError`
|
||||||
|
- `guardPath` rejects absolute paths outside sandbox → throws `SandboxEscapeError`
|
||||||
|
- `guardPathUnsafe` rejects sibling-named directories (e.g. `/tmp/test-sandbox-evil/`)
|
||||||
|
- All 12 path-guard tests pass; `SandboxEscapeError` message includes path and sandbox in text
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
- `WorkspaceService.resolvePath()` returns user path for solo projects:
|
||||||
|
`$MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>`
|
||||||
|
- `WorkspaceService.resolvePath()` returns team path for team projects:
|
||||||
|
`$MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>`
|
||||||
|
- Path resolution is deterministic (same inputs → same output)
|
||||||
|
- `exists()`, `createUserRoot()`, `createTeamRoot()` all tested
|
||||||
|
|
||||||
|
### TUI Autocomplete
|
||||||
|
|
||||||
|
- `filterCommands(commands, query)` filters by name, aliases, and description
|
||||||
|
- Empty query returns all commands
|
||||||
|
- Prefix matching works: "mo" → model, "mi" → mission
|
||||||
|
- Alias matching: "h" matches help (alias)
|
||||||
|
- Description keyword matching: "switch" → model
|
||||||
|
- Unknown query returns empty array
|
||||||
|
- `useInputHistory` ring buffer caps at 50 entries
|
||||||
|
- Up-arrow recall returns most recent entry
|
||||||
|
- Down-arrow after up restores saved input
|
||||||
|
- Duplicate consecutive entries are deduplicated
|
||||||
|
- Reset navigation works correctly
|
||||||
|
|
||||||
|
### Hot Reload
|
||||||
|
|
||||||
|
- `ReloadService` registers plugins via `registerPlugin()`
|
||||||
|
- `reload()` iterates plugins, calls their `reload()` method
|
||||||
|
- Plugin errors are counted but don't prevent other plugins from reloading
|
||||||
|
- Non-MosaicPlugin objects are skipped gracefully
|
||||||
|
- SIGHUP trigger verified via reload trigger = 'sighup'
|
||||||
|
|
||||||
|
## Gaps / Known Limitations
|
||||||
|
|
||||||
|
1. `SystemOverrideService` creates its own Valkey connection in constructor (not injected) — functional but harder to test in isolation without mocking `createQueue`. Current tests mock it at the executor level.
|
||||||
|
2. `/status` command has `execution: 'hybrid'` in the gateway registry but `execution: 'local'` in the TUI local registry — TUI local takes precedence, which is the intended behavior.
|
||||||
|
3. `SessionGCService.fullCollect()` runs on `onModuleInit` (cold start) — this is intentional but means tests must mock redis.keys to avoid real Valkey calls.
|
||||||
|
4. `ProjectBootstrapService` and `TeamsService` in workspace module have no dedicated tests — they are thin wrappers over Drizzle that delegate to WorkspaceService (which is tested).
|
||||||
|
5. GC cron schedule (`SESSION_GC_CRON` env var) is configured at module level — not unit tested here; covered by NestJS cron integration.
|
||||||
|
6. `filterCommands` in `CommandAutocomplete` is not exported — replicated in integration test to verify behavior.
|
||||||
|
|
||||||
|
## CI Evidence
|
||||||
|
|
||||||
|
Pipeline: TBD after push — all 4 local quality gates green:
|
||||||
|
|
||||||
|
- pnpm typecheck: 32 tasks, all cached/green
|
||||||
|
- pnpm lint: 18 tasks, all green
|
||||||
|
- pnpm format:check: all files match Prettier style
|
||||||
|
- pnpm test: 32 tasks, 160 tests passing
|
||||||
115
packages/auth/src/auth.test.ts
Normal file
115
packages/auth/src/auth.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { buildOAuthProviders } from './auth.js';
|
||||||
|
|
||||||
|
describe('buildOAuthProviders', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
// Clear all SSO-related env vars before each test
|
||||||
|
delete process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
|
delete process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||||
|
delete process.env['AUTHENTIK_ISSUER'];
|
||||||
|
delete process.env['WORKOS_CLIENT_ID'];
|
||||||
|
delete process.env['WORKOS_CLIENT_SECRET'];
|
||||||
|
delete process.env['KEYCLOAK_CLIENT_ID'];
|
||||||
|
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||||
|
delete process.env['KEYCLOAK_URL'];
|
||||||
|
delete process.env['KEYCLOAK_REALM'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no SSO env vars are set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
expect(providers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WorkOS', () => {
|
||||||
|
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
|
||||||
|
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||||
|
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const workos = providers.find((p) => p.providerId === 'workos');
|
||||||
|
|
||||||
|
expect(workos).toBeDefined();
|
||||||
|
expect(workos?.clientId).toBe('client_test123');
|
||||||
|
expect(workos?.authorizationUrl).toBe('https://api.workos.com/sso/authorize');
|
||||||
|
expect(workos?.tokenUrl).toBe('https://api.workos.com/sso/token');
|
||||||
|
expect(workos?.userInfoUrl).toBe('https://api.workos.com/sso/profile');
|
||||||
|
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const workos = providers.find((p) => p.providerId === 'workos');
|
||||||
|
expect(workos).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keycloak', () => {
|
||||||
|
it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => {
|
||||||
|
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||||
|
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||||
|
process.env['KEYCLOAK_URL'] = 'https://auth.example.com';
|
||||||
|
process.env['KEYCLOAK_REALM'] = 'myrealm';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const keycloak = providers.find((p) => p.providerId === 'keycloak');
|
||||||
|
|
||||||
|
expect(keycloak).toBeDefined();
|
||||||
|
expect(keycloak?.clientId).toBe('mosaic');
|
||||||
|
expect(keycloak?.discoveryUrl).toBe(
|
||||||
|
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
expect(keycloak?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes keycloak provider when KEYCLOAK_CLIENT_ID is not set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const keycloak = providers.find((p) => p.providerId === 'keycloak');
|
||||||
|
expect(keycloak).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentik', () => {
|
||||||
|
it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => {
|
||||||
|
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||||
|
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
|
||||||
|
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||||
|
|
||||||
|
expect(authentik).toBeDefined();
|
||||||
|
expect(authentik?.clientId).toBe('authentik-client');
|
||||||
|
expect(authentik?.discoveryUrl).toBe(
|
||||||
|
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes authentik provider when AUTHENTIK_CLIENT_ID is not set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||||
|
expect(authentik).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers all three providers when all env vars are set', () => {
|
||||||
|
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
|
||||||
|
process.env['WORKOS_CLIENT_ID'] = 'w-id';
|
||||||
|
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
||||||
|
process.env['KEYCLOAK_URL'] = 'https://kc.example.com';
|
||||||
|
process.env['KEYCLOAK_REALM'] = 'test';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
expect(providers).toHaveLength(3);
|
||||||
|
const ids = providers.map((p) => p.providerId);
|
||||||
|
expect(ids).toContain('authentik');
|
||||||
|
expect(ids).toContain('workos');
|
||||||
|
expect(ids).toContain('keycloak');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||||
|
import type { GenericOAuthConfig } from 'better-auth/plugins';
|
||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
@@ -9,35 +10,62 @@ export interface AuthConfig {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||||
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||||
|
const providers: GenericOAuthConfig[] = [];
|
||||||
|
|
||||||
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
|
if (authentikClientId) {
|
||||||
|
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||||
|
providers.push({
|
||||||
|
providerId: 'authentik',
|
||||||
|
clientId: authentikClientId,
|
||||||
|
clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
|
||||||
|
discoveryUrl: authentikIssuer
|
||||||
|
? `${authentikIssuer}/.well-known/openid-configuration`
|
||||||
|
: undefined,
|
||||||
|
authorizationUrl: authentikIssuer ? `${authentikIssuer}/application/o/authorize/` : undefined,
|
||||||
|
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
||||||
|
userInfoUrl: authentikIssuer ? `${authentikIssuer}/application/o/userinfo/` : undefined,
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||||
|
if (workosClientId) {
|
||||||
|
providers.push({
|
||||||
|
providerId: 'workos',
|
||||||
|
clientId: workosClientId,
|
||||||
|
clientSecret: process.env['WORKOS_CLIENT_SECRET'] ?? '',
|
||||||
|
authorizationUrl: 'https://api.workos.com/sso/authorize',
|
||||||
|
tokenUrl: 'https://api.workos.com/sso/token',
|
||||||
|
userInfoUrl: 'https://api.workos.com/sso/profile',
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
||||||
|
if (keycloakClientId) {
|
||||||
|
const keycloakUrl = process.env['KEYCLOAK_URL'] ?? '';
|
||||||
|
const keycloakRealm = process.env['KEYCLOAK_REALM'] ?? '';
|
||||||
|
providers.push({
|
||||||
|
providerId: 'keycloak',
|
||||||
|
clientId: keycloakClientId,
|
||||||
|
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '',
|
||||||
|
discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`,
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
export function createAuth(config: AuthConfig) {
|
export function createAuth(config: AuthConfig) {
|
||||||
const { db, baseURL, secret } = config;
|
const { db, baseURL, secret } = config;
|
||||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
|
||||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
const oauthProviders = buildOAuthProviders();
|
||||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
const plugins =
|
||||||
const plugins = authentikClientId
|
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
||||||
? [
|
|
||||||
genericOAuth({
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
providerId: 'authentik',
|
|
||||||
clientId: authentikClientId,
|
|
||||||
clientSecret: authentikClientSecret ?? '',
|
|
||||||
discoveryUrl: authentikIssuer
|
|
||||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
|
||||||
: undefined,
|
|
||||||
authorizationUrl: authentikIssuer
|
|
||||||
? `${authentikIssuer}/application/o/authorize/`
|
|
||||||
: undefined,
|
|
||||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
|
||||||
userInfoUrl: authentikIssuer
|
|
||||||
? `${authentikIssuer}/application/o/userinfo/`
|
|
||||||
: undefined,
|
|
||||||
scopes: ['openid', 'email', 'profile'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|||||||
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';
|
||||||
|
|||||||
82
packages/brain/src/projects.spec.ts
Normal file
82
packages/brain/src/projects.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createProjectsRepo } from './projects.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal Drizzle mock. Each call to db.select() returns a fresh
|
||||||
|
* chain that resolves `where()` to the provided rows for that call.
|
||||||
|
*
|
||||||
|
* `calls` is an ordered list: the first item is returned for the first
|
||||||
|
* db.select() call, the second for the second, and so on.
|
||||||
|
*/
|
||||||
|
function makeDb(calls: unknown[][]) {
|
||||||
|
let callIndex = 0;
|
||||||
|
const selectSpy = vi.fn(() => {
|
||||||
|
const rows = calls[callIndex++] ?? [];
|
||||||
|
const chain = {
|
||||||
|
where: vi.fn().mockResolvedValue(rows),
|
||||||
|
} as { where: ReturnType<typeof vi.fn>; from?: ReturnType<typeof vi.fn> };
|
||||||
|
// from() returns the chain so .where() can be chained, but also resolves
|
||||||
|
// directly (as a thenable) for queries with no .where() call.
|
||||||
|
chain.from = vi.fn(() => Object.assign(Promise.resolve(rows), chain));
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
return { select: selectSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createProjectsRepo — findAllForUser', () => {
|
||||||
|
it('filters by userId when user has no team memberships', async () => {
|
||||||
|
// First select: teamMembers query → empty
|
||||||
|
// Second select: projects query → one owned project
|
||||||
|
const db = makeDb([
|
||||||
|
[], // teamMembers rows
|
||||||
|
[{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' }],
|
||||||
|
]);
|
||||||
|
const repo = createProjectsRepo(db as never);
|
||||||
|
|
||||||
|
const result = await repo.findAllForUser('user-1');
|
||||||
|
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.id).toBe('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes team projects when user is a team member', async () => {
|
||||||
|
// First select: teamMembers → user belongs to one team
|
||||||
|
// Second select: projects query → two projects (own + team)
|
||||||
|
const db = makeDb([
|
||||||
|
[{ teamId: 'team-1' }],
|
||||||
|
[
|
||||||
|
{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' },
|
||||||
|
{ id: 'p2', ownerId: null, teamId: 'team-1', ownerType: 'team' },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const repo = createProjectsRepo(db as never);
|
||||||
|
|
||||||
|
const result = await repo.findAllForUser('user-1');
|
||||||
|
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when user has no projects and no teams', async () => {
|
||||||
|
const db = makeDb([[], []]);
|
||||||
|
const repo = createProjectsRepo(db as never);
|
||||||
|
|
||||||
|
const result = await repo.findAllForUser('user-no-projects');
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createProjectsRepo — findAll', () => {
|
||||||
|
it('returns all rows without any user filter', async () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' },
|
||||||
|
{ id: 'p2', ownerId: 'user-2', teamId: null, ownerType: 'user' },
|
||||||
|
];
|
||||||
|
const db = makeDb([rows]);
|
||||||
|
const repo = createProjectsRepo(db as never);
|
||||||
|
|
||||||
|
const result = await repo.findAll();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, type Db, projects } from '@mosaic/db';
|
import { eq, or, inArray, type Db, projects, teamMembers } from '@mosaic/db';
|
||||||
|
|
||||||
export type Project = typeof projects.$inferSelect;
|
export type Project = typeof projects.$inferSelect;
|
||||||
export type NewProject = typeof projects.$inferInsert;
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
@@ -9,6 +9,31 @@ export function createProjectsRepo(db: Db) {
|
|||||||
return db.select().from(projects);
|
return db.select().from(projects);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only the projects visible to a given user:
|
||||||
|
* – projects directly owned by the user (ownerType = 'user', ownerId = userId), OR
|
||||||
|
* – projects owned by a team the user belongs to (ownerType = 'team', teamId IN user's teams)
|
||||||
|
*/
|
||||||
|
async findAllForUser(userId: string): Promise<Project[]> {
|
||||||
|
// Fetch the team IDs the user is a member of.
|
||||||
|
const memberRows = await db
|
||||||
|
.select({ teamId: teamMembers.teamId })
|
||||||
|
.from(teamMembers)
|
||||||
|
.where(eq(teamMembers.userId, userId));
|
||||||
|
|
||||||
|
const teamIds = memberRows.map((r) => r.teamId);
|
||||||
|
|
||||||
|
if (teamIds.length === 0) {
|
||||||
|
// No team memberships — return only directly owned projects.
|
||||||
|
return db.select().from(projects).where(eq(projects.ownerId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(or(eq(projects.ownerId, userId), inArray(projects.teamId, teamIds)));
|
||||||
|
},
|
||||||
|
|
||||||
async findById(id: string): Promise<Project | undefined> {
|
async findById(id: string): Promise<Project | undefined> {
|
||||||
const rows = await db.select().from(projects).where(eq(projects.id, id));
|
const rows = await db.select().from(projects).where(eq(projects.id, id));
|
||||||
return rows[0];
|
return rows[0];
|
||||||
|
|||||||
@@ -21,15 +21,17 @@
|
|||||||
"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:^",
|
||||||
|
"@mosaic/types": "workspace:^",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"ink": "^5.0.0",
|
"ink": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0"
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
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 _require = createRequire(import.meta.url);
|
||||||
|
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program.name('mosaic').description('Mosaic Stack CLI').version('0.0.0');
|
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
|
||||||
|
|
||||||
// ─── login ──────────────────────────────────────────────────────────────
|
// ─── login ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,8 +57,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 +104,67 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create a conversation if none was specified
|
||||||
|
let conversationId = opts.conversation;
|
||||||
|
if (!conversationId) {
|
||||||
|
try {
|
||||||
|
const { createConversation } = await import('./tui/gateway-api.js');
|
||||||
|
const conv = await createConversation(opts.gateway, session.cookie, {
|
||||||
|
...(projectId ? { projectId } : {}),
|
||||||
|
});
|
||||||
|
conversationId = conv.id;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to create conversation: ${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');
|
||||||
@@ -97,11 +173,16 @@ program
|
|||||||
render(
|
render(
|
||||||
React.createElement(TuiApp, {
|
React.createElement(TuiApp, {
|
||||||
gatewayUrl: opts.gateway,
|
gatewayUrl: opts.gateway,
|
||||||
conversationId: opts.conversation,
|
conversationId,
|
||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
initialModel: opts.model,
|
initialModel: opts.model,
|
||||||
initialProvider: opts.provider,
|
initialProvider: opts.provider,
|
||||||
|
agentId,
|
||||||
|
agentName: agentName ?? undefined,
|
||||||
|
projectId,
|
||||||
|
version: CLI_VERSION,
|
||||||
}),
|
}),
|
||||||
|
{ exitOnCtrlC: false },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -115,23 +196,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;
|
||||||
@@ -184,6 +254,7 @@ sessionsCmd
|
|||||||
gatewayUrl: opts.gateway,
|
gatewayUrl: opts.gateway,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
|
version: CLI_VERSION,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -193,23 +264,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 +277,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 };
|
||||||
|
}
|
||||||
@@ -1,392 +1,391 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Box, Text, useInput, useApp } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
import Spinner from 'ink-spinner';
|
import { TopBar } from './components/top-bar.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { BottomBar } from './components/bottom-bar.js';
|
||||||
import { fetchAvailableModels, type ModelInfo } from './gateway-api.js';
|
import { MessageList } from './components/message-list.js';
|
||||||
|
import { InputBar } from './components/input-bar.js';
|
||||||
|
import { Sidebar } from './components/sidebar.js';
|
||||||
|
import { SearchBar } from './components/search-bar.js';
|
||||||
|
import { useSocket } from './hooks/use-socket.js';
|
||||||
|
import { useGitInfo } from './hooks/use-git-info.js';
|
||||||
|
import { useViewport } from './hooks/use-viewport.js';
|
||||||
|
import { useAppMode } from './hooks/use-app-mode.js';
|
||||||
|
import { useConversations } from './hooks/use-conversations.js';
|
||||||
|
import { useSearch } from './hooks/use-search.js';
|
||||||
|
import { executeHelp, executeStatus, commandRegistry } from './commands/index.js';
|
||||||
|
|
||||||
interface Message {
|
export interface TuiAppProps {
|
||||||
role: 'user' | 'assistant' | 'system';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TuiAppProps {
|
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
sessionCookie?: string;
|
sessionCookie?: string;
|
||||||
initialModel?: string;
|
initialModel?: string;
|
||||||
initialProvider?: string;
|
initialProvider?: string;
|
||||||
}
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
/**
|
projectId?: string;
|
||||||
* Parse a slash command from user input.
|
/** CLI package version passed from the entry point (cli.ts). */
|
||||||
* Returns null if the input is not a slash command.
|
version?: string;
|
||||||
*/
|
|
||||||
function parseSlashCommand(value: string): { command: string; args: string[] } | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed.startsWith('/')) return null;
|
|
||||||
const parts = trimmed.slice(1).split(/\s+/);
|
|
||||||
const command = parts[0]?.toLowerCase() ?? '';
|
|
||||||
const args = parts.slice(1);
|
|
||||||
return { command, args };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
conversationId: initialConversationId,
|
conversationId,
|
||||||
sessionCookie,
|
sessionCookie,
|
||||||
initialModel,
|
initialModel,
|
||||||
initialProvider,
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
projectId: _projectId,
|
||||||
|
version = '0.0.0',
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const gitInfo = useGitInfo();
|
||||||
const [input, setInput] = useState('');
|
const appMode = useAppMode();
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
||||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
|
||||||
|
|
||||||
// Model/provider state
|
const socket = useSocket({
|
||||||
const [currentModel, setCurrentModel] = useState<string | undefined>(initialModel);
|
gatewayUrl,
|
||||||
const [currentProvider, setCurrentProvider] = useState<string | undefined>(initialProvider);
|
sessionCookie,
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
initialConversationId: conversationId,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||||
const currentStreamTextRef = useRef('');
|
|
||||||
|
|
||||||
// Fetch available models on mount
|
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||||
|
|
||||||
|
const search = useSearch(socket.messages);
|
||||||
|
|
||||||
|
// Scroll to current match when it changes
|
||||||
|
const currentMatch = search.matches[search.currentMatchIndex];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAvailableModels(gatewayUrl, sessionCookie)
|
if (currentMatch && appMode.mode === 'search') {
|
||||||
.then((models) => {
|
viewport.scrollTo(currentMatch.messageIndex);
|
||||||
setAvailableModels(models);
|
}
|
||||||
// If no model/provider specified and models are available, show the default
|
}, [currentMatch, appMode.mode, viewport]);
|
||||||
if (!initialModel && !initialProvider && models.length > 0) {
|
|
||||||
const first = models[0];
|
// Compute highlighted message indices for MessageList
|
||||||
if (first) {
|
const highlightedMessageIndices = useMemo(() => {
|
||||||
setCurrentModel(first.id);
|
if (search.matches.length === 0) return undefined;
|
||||||
setCurrentProvider(first.provider);
|
return new Set(search.matches.map((m) => m.messageIndex));
|
||||||
}
|
}, [search.matches]);
|
||||||
|
|
||||||
|
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||||
|
|
||||||
|
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// Controlled input state — held here so Ctrl+C can clear it
|
||||||
|
const [tuiInput, setTuiInput] = useState('');
|
||||||
|
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||||
|
const ctrlCPendingExit = useRef(false);
|
||||||
|
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
|
||||||
|
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
||||||
|
const ctrlJustFired = useRef(false);
|
||||||
|
|
||||||
|
const handleLocalCommand = useCallback(
|
||||||
|
(parsed: ParsedCommand) => {
|
||||||
|
switch (parsed.command) {
|
||||||
|
case 'help':
|
||||||
|
case 'h': {
|
||||||
|
const result = executeHelp(parsed);
|
||||||
|
socket.addSystemMessage(result);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
})
|
case 'status':
|
||||||
.catch(() => {
|
case 's': {
|
||||||
// Non-fatal: TUI works without model list
|
const result = executeStatus(parsed, {
|
||||||
});
|
connected: socket.connected,
|
||||||
}, [gatewayUrl, sessionCookie, initialModel, initialProvider]);
|
model: socket.modelName,
|
||||||
|
provider: socket.providerName,
|
||||||
useEffect(() => {
|
sessionId: socket.conversationId ?? null,
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
tokenCount: socket.tokenUsage.total,
|
||||||
transports: ['websocket'],
|
});
|
||||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
socket.addSystemMessage(result);
|
||||||
});
|
break;
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
socket.on('connect', () => setConnected(true));
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
setConnected(false);
|
|
||||||
setIsStreaming(false);
|
|
||||||
setCurrentStreamText('');
|
|
||||||
});
|
|
||||||
socket.on('connect_error', (err: Error) => {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('message:ack', (data: { conversationId: string }) => {
|
|
||||||
setConversationId(data.conversationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:start', () => {
|
|
||||||
setIsStreaming(true);
|
|
||||||
currentStreamTextRef.current = '';
|
|
||||||
setCurrentStreamText('');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:text', (data: { text: string }) => {
|
|
||||||
currentStreamTextRef.current += data.text;
|
|
||||||
setCurrentStreamText(currentStreamTextRef.current);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:end', () => {
|
|
||||||
const finalText = currentStreamTextRef.current;
|
|
||||||
currentStreamTextRef.current = '';
|
|
||||||
setCurrentStreamText('');
|
|
||||||
if (finalText) {
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: finalText }]);
|
|
||||||
}
|
|
||||||
setIsStreaming(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (data: { error: string }) => {
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]);
|
|
||||||
setIsStreaming(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.disconnect();
|
|
||||||
};
|
|
||||||
}, [gatewayUrl]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle /model and /provider slash commands.
|
|
||||||
* Returns true if the input was a handled slash command (should not be sent to gateway).
|
|
||||||
*/
|
|
||||||
const handleSlashCommand = useCallback(
|
|
||||||
(value: string): boolean => {
|
|
||||||
const parsed = parseSlashCommand(value);
|
|
||||||
if (!parsed) return false;
|
|
||||||
|
|
||||||
const { command, args } = parsed;
|
|
||||||
|
|
||||||
if (command === 'model') {
|
|
||||||
if (args.length === 0) {
|
|
||||||
// List available models
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'No models available (could not reach gateway). Use /model <modelId> to set one manually.',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const lines = availableModels.map(
|
|
||||||
(m) =>
|
|
||||||
` ${m.provider}/${m.id}${m.id === currentModel && m.provider === currentProvider ? ' (active)' : ''}`,
|
|
||||||
);
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Available models:\n${lines.join('\n')}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Switch model: /model <modelId> or /model <provider>/<modelId>
|
|
||||||
const arg = args[0]!;
|
|
||||||
const slashIdx = arg.indexOf('/');
|
|
||||||
let newProvider: string | undefined;
|
|
||||||
let newModelId: string;
|
|
||||||
|
|
||||||
if (slashIdx !== -1) {
|
|
||||||
newProvider = arg.slice(0, slashIdx);
|
|
||||||
newModelId = arg.slice(slashIdx + 1);
|
|
||||||
} else {
|
|
||||||
newModelId = arg;
|
|
||||||
// Try to find provider from available models list
|
|
||||||
const match = availableModels.find((m) => m.id === newModelId);
|
|
||||||
newProvider = match?.provider ?? currentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentModel(newModelId);
|
|
||||||
if (newProvider) setCurrentProvider(newProvider);
|
|
||||||
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Switched to model: ${newProvider ? `${newProvider}/` : ''}${newModelId}. Takes effect on next message.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
return true;
|
case 'clear':
|
||||||
}
|
socket.clearMessages();
|
||||||
|
break;
|
||||||
if (command === 'provider') {
|
case 'new':
|
||||||
if (args.length === 0) {
|
case 'n':
|
||||||
// List providers from available models
|
void conversations
|
||||||
const providers = [...new Set(availableModels.map((m) => m.provider))];
|
.createConversation()
|
||||||
if (providers.length === 0) {
|
.then((conv) => {
|
||||||
setMessages((msgs) => [
|
if (conv) {
|
||||||
...msgs,
|
socket.switchConversation(conv.id);
|
||||||
{
|
appMode.setMode('chat');
|
||||||
role: 'system',
|
}
|
||||||
content:
|
})
|
||||||
'No providers available (could not reach gateway). Use /provider <name> to set one manually.',
|
.catch(() => {
|
||||||
},
|
socket.addSystemMessage('Failed to create new conversation.');
|
||||||
]);
|
});
|
||||||
} else {
|
break;
|
||||||
const lines = providers.map((p) => ` ${p}${p === currentProvider ? ' (active)' : ''}`);
|
case 'stop':
|
||||||
setMessages((msgs) => [
|
// Currently no stop mechanism exposed — show feedback
|
||||||
...msgs,
|
socket.addSystemMessage('Stop is not available for the current session.');
|
||||||
{
|
break;
|
||||||
role: 'system',
|
case 'cost': {
|
||||||
content: `Available providers:\n${lines.join('\n')}`,
|
const u = socket.tokenUsage;
|
||||||
},
|
socket.addSystemMessage(
|
||||||
]);
|
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||||
}
|
);
|
||||||
} else {
|
break;
|
||||||
const newProvider = args[0]!;
|
|
||||||
setCurrentProvider(newProvider);
|
|
||||||
// If switching provider, auto-select first model for that provider
|
|
||||||
const providerModels = availableModels.filter((m) => m.provider === newProvider);
|
|
||||||
if (providerModels.length > 0 && providerModels[0]) {
|
|
||||||
setCurrentModel(providerModels[0].id);
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Switched to provider: ${newProvider} (model: ${providerModels[0]!.id}). Takes effect on next message.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Switched to provider: ${newProvider}. Takes effect on next message.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
default:
|
||||||
|
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'help') {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: [
|
|
||||||
'Available commands:',
|
|
||||||
' /model — list available models',
|
|
||||||
' /model <id> — switch model (e.g. /model gpt-4o)',
|
|
||||||
' /model <p>/<id> — switch model with provider (e.g. /model ollama/llama3.2)',
|
|
||||||
' /provider — list available providers',
|
|
||||||
' /provider <name> — switch provider (e.g. /provider ollama)',
|
|
||||||
' /help — show this help',
|
|
||||||
].join('\n'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown slash command — let the user know
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Unknown command: /${command}. Type /help for available commands.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
[availableModels, currentModel, currentProvider],
|
[socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleGatewayCommand = useCallback(
|
||||||
(value: string) => {
|
(parsed: ParsedCommand) => {
|
||||||
if (!value.trim() || isStreaming) return;
|
if (!socket.socketRef.current?.connected) {
|
||||||
|
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||||
setInput('');
|
|
||||||
|
|
||||||
// Handle slash commands first
|
|
||||||
if (handleSlashCommand(value)) return;
|
|
||||||
|
|
||||||
if (!socketRef.current?.connected) {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{ role: 'assistant', content: 'Not connected to gateway. Message not sent.' },
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
socket.socketRef.current.emit('command:execute', {
|
||||||
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
conversationId: socket.conversationId ?? '',
|
||||||
|
command: parsed.command,
|
||||||
socketRef.current.emit('message', {
|
args: parsed.args ?? undefined,
|
||||||
conversationId,
|
|
||||||
content: value,
|
|
||||||
provider: currentProvider,
|
|
||||||
modelId: currentModel,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand],
|
[socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSwitchConversation = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
socket.switchConversation(id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
},
|
||||||
|
[socket, appMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteConversation = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
void conversations
|
||||||
|
.deleteConversation(id)
|
||||||
|
.then((ok) => {
|
||||||
|
if (ok && id === socket.conversationId) {
|
||||||
|
socket.clearMessages();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
[conversations, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
|
// Ctrl+C: clear input → show hint → second empty press exits
|
||||||
if (key.ctrl && ch === 'c') {
|
if (key.ctrl && ch === 'c') {
|
||||||
exit();
|
if (tuiInput) {
|
||||||
|
setTuiInput('');
|
||||||
|
ctrlCPendingExit.current = false;
|
||||||
|
} else if (ctrlCPendingExit.current) {
|
||||||
|
exit();
|
||||||
|
} else {
|
||||||
|
ctrlCPendingExit.current = true;
|
||||||
|
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any other key resets the pending-exit flag
|
||||||
|
ctrlCPendingExit.current = false;
|
||||||
|
// Ctrl+L: toggle sidebar (refresh on open)
|
||||||
|
if (key.ctrl && ch === 'l') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
const willOpen = !appMode.sidebarOpen;
|
||||||
|
appMode.toggleSidebar();
|
||||||
|
if (willOpen) {
|
||||||
|
void conversations.refresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+N: create new conversation and switch to it
|
||||||
|
if (key.ctrl && ch === 'n') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
void conversations
|
||||||
|
.createConversation()
|
||||||
|
.then((conv) => {
|
||||||
|
if (conv) {
|
||||||
|
socket.switchConversation(conv.id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+K: toggle search mode
|
||||||
|
if (key.ctrl && ch === 'k') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
if (appMode.mode === 'search') {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else {
|
||||||
|
appMode.setMode('search');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||||
|
if (appMode.mode === 'chat') {
|
||||||
|
if (key.pageUp) {
|
||||||
|
viewport.scrollBy(-viewport.viewportSize);
|
||||||
|
}
|
||||||
|
if (key.pageDown) {
|
||||||
|
viewport.scrollBy(viewport.viewportSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ctrl+T: cycle thinking level
|
||||||
|
if (key.ctrl && ch === 't') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
const levels = socket.availableThinkingLevels;
|
||||||
|
if (levels.length > 0) {
|
||||||
|
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||||
|
const nextIdx = (currentIdx + 1) % levels.length;
|
||||||
|
const next = levels[nextIdx];
|
||||||
|
if (next) {
|
||||||
|
socket.setThinkingLevel(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||||
|
if (key.escape) {
|
||||||
|
if (appMode.mode === 'search') {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else if (appMode.mode === 'sidebar') {
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else if (appMode.mode === 'chat') {
|
||||||
|
viewport.scrollToBottom();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const modelLabel = currentModel
|
const inputPlaceholder =
|
||||||
? currentProvider
|
appMode.mode === 'sidebar'
|
||||||
? `${currentProvider}/${currentModel}`
|
? 'focus is on sidebar… press Esc to return'
|
||||||
: currentModel
|
: appMode.mode === 'search'
|
||||||
: null;
|
? 'search mode… press Esc to return'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const isSearchMode = appMode.mode === 'search';
|
||||||
|
|
||||||
|
const messageArea = (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<MessageList
|
||||||
|
messages={socket.messages}
|
||||||
|
isStreaming={socket.isStreaming}
|
||||||
|
currentStreamText={socket.currentStreamText}
|
||||||
|
currentThinkingText={socket.currentThinkingText}
|
||||||
|
activeToolCalls={socket.activeToolCalls}
|
||||||
|
scrollOffset={viewport.scrollOffset}
|
||||||
|
viewportSize={viewport.viewportSize}
|
||||||
|
isScrolledUp={viewport.isScrolledUp}
|
||||||
|
highlightedMessageIndices={highlightedMessageIndices}
|
||||||
|
currentHighlightIndex={currentHighlightIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSearchMode && (
|
||||||
|
<SearchBar
|
||||||
|
query={search.query}
|
||||||
|
onQueryChange={search.setQuery}
|
||||||
|
totalMatches={search.totalMatches}
|
||||||
|
currentMatch={search.currentMatchIndex}
|
||||||
|
onNext={search.nextMatch}
|
||||||
|
onPrev={search.prevMatch}
|
||||||
|
onClose={() => {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}}
|
||||||
|
focused={isSearchMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputBar
|
||||||
|
value={tuiInput}
|
||||||
|
onChange={(val: string) => {
|
||||||
|
// Suppress the character that ink-text-input leaks when a Ctrl+key
|
||||||
|
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
|
||||||
|
// set synchronously in the useInput handler and cleared via a
|
||||||
|
// microtask, so this callback sees it as still true on the same
|
||||||
|
// event-loop tick.
|
||||||
|
if (ctrlJustFired.current) {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTuiInput(val);
|
||||||
|
}}
|
||||||
|
onSubmit={socket.sendMessage}
|
||||||
|
onSystemMessage={socket.addSystemMessage}
|
||||||
|
onLocalCommand={handleLocalCommand}
|
||||||
|
onGatewayCommand={handleGatewayCommand}
|
||||||
|
isStreaming={socket.isStreaming}
|
||||||
|
connected={socket.connected}
|
||||||
|
focused={appMode.mode === 'chat'}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
allCommands={commandRegistry.getAll()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" height="100%">
|
||||||
<Box marginBottom={1}>
|
<Box marginTop={1} />
|
||||||
<Text bold color="blue">
|
<TopBar
|
||||||
Mosaic
|
gatewayUrl={gatewayUrl}
|
||||||
</Text>
|
version={version}
|
||||||
<Text> </Text>
|
modelName={socket.modelName}
|
||||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
thinkingLevel={socket.thinkingLevel}
|
||||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
{modelLabel && (
|
agentName={agentName ?? 'default'}
|
||||||
<>
|
connected={socket.connected}
|
||||||
<Text dimColor> | </Text>
|
connecting={socket.connecting}
|
||||||
<Text color="yellow">{modelLabel}</Text>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
{appMode.sidebarOpen ? (
|
||||||
{messages.map((msg, i) => (
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
<Box key={i} marginBottom={1}>
|
<Sidebar
|
||||||
{msg.role === 'system' ? (
|
conversations={conversations.conversations}
|
||||||
<Text dimColor italic>
|
activeConversationId={socket.conversationId}
|
||||||
{msg.content}
|
selectedIndex={sidebarSelectedIndex}
|
||||||
</Text>
|
onSelectIndex={setSidebarSelectedIndex}
|
||||||
) : (
|
onSwitchConversation={handleSwitchConversation}
|
||||||
<>
|
onDeleteConversation={handleDeleteConversation}
|
||||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
loading={conversations.loading}
|
||||||
{msg.role === 'user' ? '> ' : ' '}
|
focused={appMode.mode === 'sidebar'}
|
||||||
</Text>
|
width={30}
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
/>
|
||||||
</>
|
{messageArea}
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
) : (
|
||||||
))}
|
<Box flexGrow={1}>{messageArea}</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{isStreaming && currentStreamText && (
|
<BottomBar
|
||||||
<Box marginBottom={1}>
|
gitInfo={gitInfo}
|
||||||
<Text bold color="cyan">
|
tokenUsage={socket.tokenUsage}
|
||||||
{' '}
|
connected={socket.connected}
|
||||||
</Text>
|
connecting={socket.connecting}
|
||||||
<Text wrap="wrap">{currentStreamText}</Text>
|
modelName={socket.modelName}
|
||||||
</Box>
|
providerName={socket.providerName}
|
||||||
)}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
|
conversationId={socket.conversationId}
|
||||||
{isStreaming && !currentStreamText && (
|
/>
|
||||||
<Box>
|
|
||||||
<Text color="cyan">
|
|
||||||
<Spinner type="dots" />
|
|
||||||
</Text>
|
|
||||||
<Text dimColor> thinking...</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text bold color="green">
|
|
||||||
{'> '}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
placeholder={isStreaming ? 'waiting...' : 'type a message or /help'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
348
packages/cli/src/tui/commands/commands.integration.spec.ts
Normal file
348
packages/cli/src/tui/commands/commands.integration.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for TUI command parsing + registry (P8-019)
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
||||||
|
* - /help, /stop, /cost, /status resolve to 'local' execution
|
||||||
|
* - Unknown commands return null from find()
|
||||||
|
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
||||||
|
* - filterCommands prefix filtering
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { parseSlashCommand } from './parse.js';
|
||||||
|
import { CommandRegistry } from './registry.js';
|
||||||
|
import type { CommandDef } from '@mosaic/types';
|
||||||
|
|
||||||
|
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
||||||
|
let registry: CommandRegistry;
|
||||||
|
|
||||||
|
// Gateway-style commands to simulate a live manifest
|
||||||
|
const gatewayCommands: CommandDef[] = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
description: 'Switch the active model',
|
||||||
|
aliases: ['m'],
|
||||||
|
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thinking',
|
||||||
|
description: 'Set thinking level',
|
||||||
|
aliases: ['t'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'level',
|
||||||
|
type: 'enum',
|
||||||
|
optional: false,
|
||||||
|
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||||
|
description: 'Thinking level',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent',
|
||||||
|
description: 'Switch or list available agents',
|
||||||
|
aliases: ['a'],
|
||||||
|
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preferences',
|
||||||
|
description: 'View or set user preferences',
|
||||||
|
aliases: ['pref'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'enum',
|
||||||
|
optional: true,
|
||||||
|
values: ['show', 'set', 'reset'],
|
||||||
|
description: 'Action',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'rest',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'Trigger garbage collection sweep',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mission',
|
||||||
|
description: 'View or set active mission',
|
||||||
|
aliases: [],
|
||||||
|
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new CommandRegistry();
|
||||||
|
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseSlashCommand tests ──
|
||||||
|
|
||||||
|
it('returns null for non-slash input', () => {
|
||||||
|
expect(parseSlashCommand('hello world')).toBeNull();
|
||||||
|
expect(parseSlashCommand('')).toBeNull();
|
||||||
|
expect(parseSlashCommand('model')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
||||||
|
const parsed = parseSlashCommand('/model claude-3-opus');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.command).toBe('model');
|
||||||
|
expect(parsed!.args).toBe('claude-3-opus');
|
||||||
|
expect(parsed!.raw).toBe('/model claude-3-opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/gc" with no args → command=gc args=null', () => {
|
||||||
|
const parsed = parseSlashCommand('/gc');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.command).toBe('gc');
|
||||||
|
expect(parsed!.args).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
||||||
|
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
||||||
|
expect(parsed!.command).toBe('system');
|
||||||
|
expect(parsed!.args).toBe('you are a helpful assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/help" → command=help args=null', () => {
|
||||||
|
const parsed = parseSlashCommand('/help');
|
||||||
|
expect(parsed!.command).toBe('help');
|
||||||
|
expect(parsed!.args).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Round-trip: parse then find ──
|
||||||
|
|
||||||
|
it('round-trip: /m → resolves to "model" command via alias', () => {
|
||||||
|
const parsed = parseSlashCommand('/m claude-3-haiku');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
// /m → model (alias map in registry)
|
||||||
|
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /h → resolves to "help" (local command)', () => {
|
||||||
|
const parsed = parseSlashCommand('/h');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
||||||
|
const parsed = parseSlashCommand('/n');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
||||||
|
const parsed = parseSlashCommand('/a list');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
||||||
|
const parsed = parseSlashCommand('/pref show');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
||||||
|
const parsed = parseSlashCommand('/t high');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Local commands resolve to 'local' execution ──
|
||||||
|
|
||||||
|
it('/help resolves to local execution', () => {
|
||||||
|
const cmd = registry.find('help');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/stop resolves to local execution', () => {
|
||||||
|
const cmd = registry.find('stop');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/cost resolves to local execution', () => {
|
||||||
|
const cmd = registry.find('cost');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/status resolves to local execution (TUI local override)', () => {
|
||||||
|
const cmd = registry.find('status');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unknown commands return null ──
|
||||||
|
|
||||||
|
it('find() returns null for unknown command', () => {
|
||||||
|
expect(registry.find('nonexistent')).toBeNull();
|
||||||
|
expect(registry.find('xyz')).toBeNull();
|
||||||
|
expect(registry.find('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('find() returns null when no gateway manifest and command not local', () => {
|
||||||
|
const emptyRegistry = new CommandRegistry();
|
||||||
|
expect(emptyRegistry.find('model')).toBeNull();
|
||||||
|
expect(emptyRegistry.find('gc')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getAll returns combined local + gateway ──
|
||||||
|
|
||||||
|
it('getAll() includes both local and gateway commands', () => {
|
||||||
|
const all = registry.getAll();
|
||||||
|
const names = all.map((c) => c.name);
|
||||||
|
// Local commands
|
||||||
|
expect(names).toContain('help');
|
||||||
|
expect(names).toContain('stop');
|
||||||
|
expect(names).toContain('cost');
|
||||||
|
expect(names).toContain('status');
|
||||||
|
// Gateway commands
|
||||||
|
expect(names).toContain('model');
|
||||||
|
expect(names).toContain('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLocalCommands() returns only local commands', () => {
|
||||||
|
const local = registry.getLocalCommands();
|
||||||
|
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
||||||
|
expect(local.some((c) => c.name === 'help')).toBe(true);
|
||||||
|
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('filterCommands (from CommandAutocomplete)', () => {
|
||||||
|
// Import inline since filterCommands is not exported — replicate the logic here
|
||||||
|
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||||
|
if (!query) return commands;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return commands.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.includes(q) ||
|
||||||
|
c.aliases.some((a) => a.includes(q)) ||
|
||||||
|
c.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: CommandDef[] = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
description: 'Switch the active model',
|
||||||
|
aliases: ['m'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mission',
|
||||||
|
description: 'View or set active mission',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show available commands',
|
||||||
|
aliases: ['h'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'Trigger garbage collection sweep',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('returns all commands when query is empty', () => {
|
||||||
|
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
||||||
|
const result = filterCommands(commands, 'mi');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('mission');
|
||||||
|
expect(names).not.toContain('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by name prefix "mo" → model only', () => {
|
||||||
|
const result = filterCommands(commands, 'mo');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('model');
|
||||||
|
expect(names).not.toContain('mission');
|
||||||
|
expect(names).not.toContain('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by exact name "gc" → gc only', () => {
|
||||||
|
const result = filterCommands(commands, 'gc');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.name).toBe('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by alias "h" → help', () => {
|
||||||
|
const result = filterCommands(commands, 'h');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by description keyword "switch" → model', () => {
|
||||||
|
const result = filterCommands(commands, 'switch');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no commands match', () => {
|
||||||
|
const result = filterCommands(commands, 'zzznotfound');
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/cli/src/tui/commands/index.ts
Normal file
5
packages/cli/src/tui/commands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { parseSlashCommand } from './parse.js';
|
||||||
|
export { commandRegistry, CommandRegistry } from './registry.js';
|
||||||
|
export { executeHelp } from './local/help.js';
|
||||||
|
export { executeStatus } from './local/status.js';
|
||||||
|
export type { StatusContext } from './local/status.js';
|
||||||
19
packages/cli/src/tui/commands/local/help.ts
Normal file
19
packages/cli/src/tui/commands/local/help.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
import { commandRegistry } from '../registry.js';
|
||||||
|
|
||||||
|
export function executeHelp(_parsed: ParsedCommand): string {
|
||||||
|
const commands = commandRegistry.getAll();
|
||||||
|
const lines = ['Available commands:', ''];
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const aliases =
|
||||||
|
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||||
|
const argsStr =
|
||||||
|
cmd.args && cmd.args.length > 0
|
||||||
|
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
||||||
|
: '';
|
||||||
|
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n').trimEnd();
|
||||||
|
}
|
||||||
20
packages/cli/src/tui/commands/local/status.ts
Normal file
20
packages/cli/src/tui/commands/local/status.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
|
||||||
|
export interface StatusContext {
|
||||||
|
connected: boolean;
|
||||||
|
model: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
tokenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
||||||
|
const lines = [
|
||||||
|
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
||||||
|
`Model: ${ctx.model ?? 'unknown'}`,
|
||||||
|
`Provider: ${ctx.provider ?? 'unknown'}`,
|
||||||
|
`Session: ${ctx.sessionId ?? 'none'}`,
|
||||||
|
`Tokens (session): ${ctx.tokenCount}`,
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
11
packages/cli/src/tui/commands/parse.ts
Normal file
11
packages/cli/src/tui/commands/parse.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
|
||||||
|
export function parseSlashCommand(input: string): ParsedCommand | null {
|
||||||
|
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
command: match[1]!,
|
||||||
|
args: match[2]?.trim() || null,
|
||||||
|
raw: input,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
packages/cli/src/tui/commands/registry.ts
Normal file
111
packages/cli/src/tui/commands/registry.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||||
|
|
||||||
|
// Local-only commands (work even when gateway is disconnected)
|
||||||
|
const LOCAL_COMMANDS: CommandDef[] = [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show available commands',
|
||||||
|
aliases: ['h'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stop',
|
||||||
|
description: 'Cancel current streaming response',
|
||||||
|
aliases: [],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cost',
|
||||||
|
description: 'Show token usage and cost for current session',
|
||||||
|
aliases: [],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
description: 'Show connection and session status',
|
||||||
|
aliases: ['s'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clear',
|
||||||
|
description: 'Clear the current conversation display',
|
||||||
|
aliases: [],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
m: 'model',
|
||||||
|
t: 'thinking',
|
||||||
|
a: 'agent',
|
||||||
|
s: 'status',
|
||||||
|
h: 'help',
|
||||||
|
pref: 'preferences',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CommandRegistry {
|
||||||
|
private gatewayManifest: CommandManifest | null = null;
|
||||||
|
|
||||||
|
updateManifest(manifest: CommandManifest): void {
|
||||||
|
this.gatewayManifest = manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAlias(command: string): string {
|
||||||
|
return ALIASES[command] ?? command;
|
||||||
|
}
|
||||||
|
|
||||||
|
find(command: string): CommandDef | null {
|
||||||
|
const resolved = this.resolveAlias(command);
|
||||||
|
// Search local first, then gateway manifest
|
||||||
|
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
||||||
|
if (local) return local;
|
||||||
|
if (this.gatewayManifest) {
|
||||||
|
return (
|
||||||
|
this.gatewayManifest.commands.find(
|
||||||
|
(c) => c.name === resolved || c.aliases.includes(resolved),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): CommandDef[] {
|
||||||
|
const gateway = this.gatewayManifest?.commands ?? [];
|
||||||
|
// Local commands take precedence; deduplicate gateway commands that share
|
||||||
|
// a name with a local command to avoid duplicate React keys and confusing
|
||||||
|
// autocomplete entries.
|
||||||
|
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
|
||||||
|
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
|
||||||
|
return [...LOCAL_COMMANDS, ...dedupedGateway];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalCommands(): CommandDef[] {
|
||||||
|
return LOCAL_COMMANDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commandRegistry = new CommandRegistry();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user