Compare commits
19 Commits
bfbe9fff97
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 66dd3ee995 | |||
| cbfd6fb996 | |||
| 3f8553ce07 | |||
| bf668e18f1 | |||
| 1f2b8125c6 | |||
| 93645295d5 | |||
| 7a52652be6 | |||
| 791c8f505e | |||
| 12653477d6 | |||
| dedfa0d9ac | |||
| c1d3dfd77e | |||
| f0476cae92 | |||
| b6effdcd6b | |||
| 39ef2ff123 | |||
| a989b5e549 | |||
| ff27e944a1 | |||
| 0821393c1d | |||
| 24f5c0699a | |||
| 96409c40bf |
@@ -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(),
|
||||||
@@ -67,7 +68,8 @@ 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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { CommandsModule } from './commands/commands.module.js';
|
|||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
import { ReloadModule } from './reload/reload.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({
|
||||||
@@ -46,6 +47,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
|
WorkspaceModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { ReloadService } from '../reload/reload.service.js';
|
import { ReloadService } from '../reload/reload.service.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,6 +18,7 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
private readonly reloadService: ReloadService | null,
|
private readonly reloadService: ReloadService | null,
|
||||||
@@ -83,6 +86,22 @@ export class CommandExecutorService {
|
|||||||
conversationId,
|
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': {
|
case 'reload': {
|
||||||
if (!this.reloadService) {
|
if (!this.reloadService) {
|
||||||
return {
|
return {
|
||||||
@@ -190,4 +209,165 @@ export class CommandExecutorService {
|
|||||||
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,70 @@ export class CommandRegistryService implements OnModuleInit {
|
|||||||
execution: 'socket',
|
execution: 'socket',
|
||||||
available: true,
|
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',
|
name: 'reload',
|
||||||
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,13 +1,37 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||||
providers: [CommandRegistryService, CommandExecutorService],
|
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],
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
})
|
})
|
||||||
export class CommandsModule {}
|
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';
|
||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||||
|
|
||||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||||
const TTL_SECONDS = 5 * 60; // 5 minutes, renewed on each turn
|
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()
|
@Injectable()
|
||||||
export class SystemOverrideService {
|
export class SystemOverrideService {
|
||||||
@@ -14,8 +21,32 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string): Promise<void> {
|
async set(sessionId: string, override: string): Promise<void> {
|
||||||
await this.handle.redis.setex(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS, override);
|
// Load existing fragments
|
||||||
this.logger.debug(`Set system override for session ${sessionId} (TTL=${TTL_SECONDS}s)`);
|
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> {
|
async get(sessionId: string): Promise<string | null> {
|
||||||
@@ -23,11 +54,78 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string): Promise<void> {
|
async renew(sessionId: string): Promise<void> {
|
||||||
await this.handle.redis.expire(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS);
|
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> {
|
async clear(sessionId: string): Promise<void> {
|
||||||
await this.handle.redis.del(SESSION_SYSTEM_KEY(sessionId));
|
await this.handle.redis.del(
|
||||||
|
SESSION_SYSTEM_KEY(sessionId),
|
||||||
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||||
|
);
|
||||||
this.logger.debug(`Cleared system override for session ${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 {}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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) | in-progress | — | — | 2026-03-15 | — |
|
| 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
|
||||||
|
|
||||||
|
|||||||
188
docs/TASKS.md
188
docs/TASKS.md
@@ -1,96 +1,100 @@
|
|||||||
# Tasks — MVP
|
# Tasks — MVP
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||||
|
> Pipeline crons pick the cheapest capable model. Override with a specific value when a task genuinely needs it.
|
||||||
|
> Examples: `opus` for major architecture decisions, `codex` for pure coding, `haiku` for review/verify gates, `glm-5` for cost-sensitive coding.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | agent | 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-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
|
| P8-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
|
||||||
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
|
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
|
||||||
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
|
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
|
||||||
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
|
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
|
||||||
| P8-009 | not-started | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | — | #162 |
|
| P8-009 | done | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | #176 | #162 |
|
||||||
| P8-010 | not-started | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | — | #163 |
|
| P8-010 | done | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | #178 | #163 |
|
||||||
| P8-011 | not-started | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | — | #164 |
|
| P8-011 | done | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | #180 | #164 |
|
||||||
| P8-012 | not-started | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | — | #165 |
|
| P8-012 | done | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | #181 | #165 |
|
||||||
| P8-013 | not-started | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | — | #166 |
|
| P8-013 | done | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | #182 | #166 |
|
||||||
| P8-014 | not-started | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | — | #167 |
|
| P8-014 | done | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | #179 | #167 |
|
||||||
| P8-015 | not-started | Phase 8 | Gateway Phase 7 — WorkspaceService, ProjectBootstrapService, teams project ownership | — | #168 |
|
| 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 | — | #169 |
|
| P8-016 | done | Phase 8 | Security — file/git/shell tool strict path hardening, sandbox escape prevention | #177 | #169 |
|
||||||
| P8-017 | not-started | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | — | #170 |
|
| 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-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
|
||||||
| P8-019 | not-started | Phase 8 | Verify Platform Architecture — integration + E2E verification | — | #172 |
|
| 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-001 | not-started | codex | 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-002 | not-started | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||||
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
|
| P8-003 | not-started | codex | Phase 8 | Performance optimization | — | #56 |
|
||||||
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
| P8-004 | not-started | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||||
|
|||||||
40
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
40
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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)
|
||||||
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.
|
||||||
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
|
||||||
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];
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerPrdyCommand } from './commands/prdy.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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -144,6 +148,23 @@ program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
@@ -152,14 +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,
|
agentId,
|
||||||
agentName: agentName ?? undefined,
|
agentName: agentName ?? undefined,
|
||||||
projectId,
|
projectId,
|
||||||
|
version: CLI_VERSION,
|
||||||
}),
|
}),
|
||||||
|
{ exitOnCtrlC: false },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -231,6 +254,7 @@ sessionsCmd
|
|||||||
gatewayUrl: opts.gateway,
|
gatewayUrl: opts.gateway,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
|
version: CLI_VERSION,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Box, useApp, useInput } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import type { ParsedCommand } from '@mosaic/types';
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
import { TopBar } from './components/top-bar.js';
|
import { TopBar } from './components/top-bar.js';
|
||||||
@@ -13,7 +13,7 @@ import { useViewport } from './hooks/use-viewport.js';
|
|||||||
import { useAppMode } from './hooks/use-app-mode.js';
|
import { useAppMode } from './hooks/use-app-mode.js';
|
||||||
import { useConversations } from './hooks/use-conversations.js';
|
import { useConversations } from './hooks/use-conversations.js';
|
||||||
import { useSearch } from './hooks/use-search.js';
|
import { useSearch } from './hooks/use-search.js';
|
||||||
import { executeHelp, executeStatus } from './commands/index.js';
|
import { executeHelp, executeStatus, commandRegistry } from './commands/index.js';
|
||||||
|
|
||||||
export interface TuiAppProps {
|
export interface TuiAppProps {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@@ -24,6 +24,8 @@ export interface TuiAppProps {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** CLI package version passed from the entry point (cli.ts). */
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
@@ -35,6 +37,7 @@ export function TuiApp({
|
|||||||
agentId,
|
agentId,
|
||||||
agentName,
|
agentName,
|
||||||
projectId: _projectId,
|
projectId: _projectId,
|
||||||
|
version = '0.0.0',
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const gitInfo = useGitInfo();
|
const gitInfo = useGitInfo();
|
||||||
@@ -73,6 +76,14 @@ export function TuiApp({
|
|||||||
|
|
||||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
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(
|
const handleLocalCommand = useCallback(
|
||||||
(parsed: ParsedCommand) => {
|
(parsed: ParsedCommand) => {
|
||||||
switch (parsed.command) {
|
switch (parsed.command) {
|
||||||
@@ -97,6 +108,20 @@ export function TuiApp({
|
|||||||
case 'clear':
|
case 'clear':
|
||||||
socket.clearMessages();
|
socket.clearMessages();
|
||||||
break;
|
break;
|
||||||
|
case 'new':
|
||||||
|
case 'n':
|
||||||
|
void conversations
|
||||||
|
.createConversation()
|
||||||
|
.then((conv) => {
|
||||||
|
if (conv) {
|
||||||
|
socket.switchConversation(conv.id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
socket.addSystemMessage('Failed to create new conversation.');
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'stop':
|
case 'stop':
|
||||||
// Currently no stop mechanism exposed — show feedback
|
// Currently no stop mechanism exposed — show feedback
|
||||||
socket.addSystemMessage('Stop is not available for the current session.');
|
socket.addSystemMessage('Stop is not available for the current session.');
|
||||||
@@ -117,12 +142,12 @@ export function TuiApp({
|
|||||||
|
|
||||||
const handleGatewayCommand = useCallback(
|
const handleGatewayCommand = useCallback(
|
||||||
(parsed: ParsedCommand) => {
|
(parsed: ParsedCommand) => {
|
||||||
if (!socket.socketRef.current?.connected || !socket.conversationId) {
|
if (!socket.socketRef.current?.connected) {
|
||||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socket.socketRef.current.emit('command:execute', {
|
socket.socketRef.current.emit('command:execute', {
|
||||||
conversationId: socket.conversationId,
|
conversationId: socket.conversationId ?? '',
|
||||||
command: parsed.command,
|
command: parsed.command,
|
||||||
args: parsed.args ?? undefined,
|
args: parsed.args ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -153,19 +178,40 @@ export function TuiApp({
|
|||||||
);
|
);
|
||||||
|
|
||||||
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)
|
// Ctrl+L: toggle sidebar (refresh on open)
|
||||||
if (key.ctrl && ch === 'l') {
|
if (key.ctrl && ch === 'l') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
const willOpen = !appMode.sidebarOpen;
|
const willOpen = !appMode.sidebarOpen;
|
||||||
appMode.toggleSidebar();
|
appMode.toggleSidebar();
|
||||||
if (willOpen) {
|
if (willOpen) {
|
||||||
void conversations.refresh();
|
void conversations.refresh();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+N: create new conversation and switch to it
|
// Ctrl+N: create new conversation and switch to it
|
||||||
if (key.ctrl && ch === 'n') {
|
if (key.ctrl && ch === 'n') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
void conversations
|
void conversations
|
||||||
.createConversation()
|
.createConversation()
|
||||||
.then((conv) => {
|
.then((conv) => {
|
||||||
@@ -175,15 +221,21 @@ export function TuiApp({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+K: toggle search mode
|
// Ctrl+K: toggle search mode
|
||||||
if (key.ctrl && ch === 'k') {
|
if (key.ctrl && ch === 'k') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
if (appMode.mode === 'search') {
|
if (appMode.mode === 'search') {
|
||||||
search.clear();
|
search.clear();
|
||||||
appMode.setMode('chat');
|
appMode.setMode('chat');
|
||||||
} else {
|
} else {
|
||||||
appMode.setMode('search');
|
appMode.setMode('search');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||||
if (appMode.mode === 'chat') {
|
if (appMode.mode === 'chat') {
|
||||||
@@ -196,6 +248,10 @@ export function TuiApp({
|
|||||||
}
|
}
|
||||||
// Ctrl+T: cycle thinking level
|
// Ctrl+T: cycle thinking level
|
||||||
if (key.ctrl && ch === 't') {
|
if (key.ctrl && ch === 't') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
const levels = socket.availableThinkingLevels;
|
const levels = socket.availableThinkingLevels;
|
||||||
if (levels.length > 0) {
|
if (levels.length > 0) {
|
||||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||||
@@ -205,6 +261,7 @@ export function TuiApp({
|
|||||||
socket.setThinkingLevel(next);
|
socket.setThinkingLevel(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
@@ -260,13 +317,28 @@ export function TuiApp({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<InputBar
|
<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}
|
onSubmit={socket.sendMessage}
|
||||||
onSystemMessage={socket.addSystemMessage}
|
onSystemMessage={socket.addSystemMessage}
|
||||||
onLocalCommand={handleLocalCommand}
|
onLocalCommand={handleLocalCommand}
|
||||||
onGatewayCommand={handleGatewayCommand}
|
onGatewayCommand={handleGatewayCommand}
|
||||||
isStreaming={socket.isStreaming}
|
isStreaming={socket.isStreaming}
|
||||||
connected={socket.connected}
|
connected={socket.connected}
|
||||||
|
focused={appMode.mode === 'chat'}
|
||||||
placeholder={inputPlaceholder}
|
placeholder={inputPlaceholder}
|
||||||
|
allCommands={commandRegistry.getAll()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -276,7 +348,7 @@ export function TuiApp({
|
|||||||
<Box marginTop={1} />
|
<Box marginTop={1} />
|
||||||
<TopBar
|
<TopBar
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
version="0.0.0"
|
version={version}
|
||||||
modelName={socket.modelName}
|
modelName={socket.modelName}
|
||||||
thinkingLevel={socket.thinkingLevel}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
contextWindow={socket.tokenUsage.contextWindow}
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,10 +47,18 @@ const LOCAL_COMMANDS: CommandDef[] = [
|
|||||||
available: true,
|
available: true,
|
||||||
scope: 'core',
|
scope: 'core',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALIASES: Record<string, string> = {
|
const ALIASES: Record<string, string> = {
|
||||||
n: 'new',
|
|
||||||
m: 'model',
|
m: 'model',
|
||||||
t: 'thinking',
|
t: 'thinking',
|
||||||
a: 'agent',
|
a: 'agent',
|
||||||
@@ -87,7 +95,12 @@ export class CommandRegistry {
|
|||||||
|
|
||||||
getAll(): CommandDef[] {
|
getAll(): CommandDef[] {
|
||||||
const gateway = this.gatewayManifest?.commands ?? [];
|
const gateway = this.gatewayManifest?.commands ?? [];
|
||||||
return [...LOCAL_COMMANDS, ...gateway];
|
// 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[] {
|
getLocalCommands(): CommandDef[] {
|
||||||
|
|||||||
66
packages/cli/src/tui/components/command-autocomplete.tsx
Normal file
66
packages/cli/src/tui/components/command-autocomplete.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { CommandDef, CommandArgDef } from '@mosaic/types';
|
||||||
|
|
||||||
|
interface CommandAutocompleteProps {
|
||||||
|
commands: CommandDef[];
|
||||||
|
selectedIndex: number;
|
||||||
|
inputValue: string; // the current input after '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandAutocomplete({
|
||||||
|
commands,
|
||||||
|
selectedIndex,
|
||||||
|
inputValue,
|
||||||
|
}: CommandAutocompleteProps) {
|
||||||
|
if (commands.length === 0) return null;
|
||||||
|
|
||||||
|
// Filter by inputValue prefix/fuzzy match
|
||||||
|
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
|
||||||
|
const filtered = filterCommands(commands, query);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
|
||||||
|
const selected = filtered[clampedIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||||
|
{filtered.slice(0, 8).map((cmd, i) => (
|
||||||
|
<Box key={`${cmd.execution}-${cmd.name}`}>
|
||||||
|
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
||||||
|
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
||||||
|
</Text>
|
||||||
|
{cmd.aliases.length > 0 && (
|
||||||
|
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
|
||||||
|
)}
|
||||||
|
<Text color="gray"> — {cmd.description}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{selected && selected.args && selected.args.length > 0 && (
|
||||||
|
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Text color="yellow">
|
||||||
|
/{selected.name} {getArgHint(selected.args)}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray"> — {selected.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArgHint(args: CommandArgDef[]): string {
|
||||||
|
if (!args || args.length === 0) return '';
|
||||||
|
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
|
||||||
|
}
|
||||||
@@ -1,29 +1,62 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import type { ParsedCommand } from '@mosaic/types';
|
import type { ParsedCommand, CommandDef } from '@mosaic/types';
|
||||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||||
|
import { CommandAutocomplete } from './command-autocomplete.js';
|
||||||
|
import { useInputHistory } from '../hooks/use-input-history.js';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export interface InputBarProps {
|
export interface InputBarProps {
|
||||||
|
/** Controlled input value — caller owns the state */
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
onSubmit: (value: string) => void;
|
onSubmit: (value: string) => void;
|
||||||
onSystemMessage?: (message: string) => void;
|
onSystemMessage?: (message: string) => void;
|
||||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
/** Whether this input bar is focused/active (default true). When false,
|
||||||
|
* keyboard input is not captured — e.g. when the sidebar has focus. */
|
||||||
|
focused?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
allCommands?: CommandDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputBar({
|
export function InputBar({
|
||||||
|
value: input,
|
||||||
|
onChange: setInput,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onSystemMessage,
|
onSystemMessage,
|
||||||
onLocalCommand,
|
onLocalCommand,
|
||||||
onGatewayCommand,
|
onGatewayCommand,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
connected,
|
connected,
|
||||||
|
focused = true,
|
||||||
placeholder: placeholderOverride,
|
placeholder: placeholderOverride,
|
||||||
|
allCommands,
|
||||||
}: InputBarProps) {
|
}: InputBarProps) {
|
||||||
const [input, setInput] = useState('');
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||||
|
|
||||||
|
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
||||||
|
|
||||||
|
// Determine which commands to show in autocomplete
|
||||||
|
const availableCommands = allCommands ?? commandRegistry.getAll();
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setInput(value);
|
||||||
|
if (value.startsWith('/')) {
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
} else {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setInput],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -31,11 +64,14 @@ export function InputBar({
|
|||||||
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
addToHistory(trimmed);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
|
||||||
if (trimmed.startsWith('/')) {
|
if (trimmed.startsWith('/')) {
|
||||||
const parsed = parseSlashCommand(trimmed);
|
const parsed = parseSlashCommand(trimmed);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
onSystemMessage?.(`Unknown command format: ${trimmed}`);
|
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||||
setInput('');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const def = commandRegistry.find(parsed.command);
|
const def = commandRegistry.find(parsed.command);
|
||||||
@@ -60,7 +96,99 @@ export function InputBar({
|
|||||||
onSubmit(value);
|
onSubmit(value);
|
||||||
setInput('');
|
setInput('');
|
||||||
},
|
},
|
||||||
[onSubmit, onSystemMessage, onLocalCommand, onGatewayCommand, isStreaming, connected],
|
[
|
||||||
|
onSubmit,
|
||||||
|
onSystemMessage,
|
||||||
|
onLocalCommand,
|
||||||
|
onGatewayCommand,
|
||||||
|
isStreaming,
|
||||||
|
connected,
|
||||||
|
addToHistory,
|
||||||
|
setInput,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle Tab: fill in selected autocomplete command
|
||||||
|
const fillAutocompleteSelection = useCallback(() => {
|
||||||
|
if (!showAutocomplete) return false;
|
||||||
|
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||||
|
const filtered = availableCommands.filter(
|
||||||
|
(c) =>
|
||||||
|
!query ||
|
||||||
|
c.name.includes(query.toLowerCase()) ||
|
||||||
|
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||||
|
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) return false;
|
||||||
|
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
||||||
|
const selected = filtered[idx];
|
||||||
|
if (selected) {
|
||||||
|
setInput(`/${selected.name} `);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(_ch, key) => {
|
||||||
|
if (key.escape && showAutocomplete) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab: fill autocomplete selection
|
||||||
|
if (key.tab) {
|
||||||
|
fillAutocompleteSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up arrow
|
||||||
|
if (key.upArrow) {
|
||||||
|
if (showAutocomplete) {
|
||||||
|
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
} else {
|
||||||
|
const prev = navigateUp(input);
|
||||||
|
if (prev !== null) {
|
||||||
|
setInput(prev);
|
||||||
|
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down arrow
|
||||||
|
if (key.downArrow) {
|
||||||
|
if (showAutocomplete) {
|
||||||
|
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||||
|
const filteredLen = availableCommands.filter(
|
||||||
|
(c) =>
|
||||||
|
!query ||
|
||||||
|
c.name.includes(query.toLowerCase()) ||
|
||||||
|
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||||
|
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
).length;
|
||||||
|
const maxVisible = Math.min(filteredLen, 8);
|
||||||
|
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||||
|
} else {
|
||||||
|
const next = navigateDown();
|
||||||
|
if (next !== null) {
|
||||||
|
setInput(next);
|
||||||
|
setShowAutocomplete(next.startsWith('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return/Enter on autocomplete: fill selected command
|
||||||
|
if (key.return && showAutocomplete) {
|
||||||
|
fillAutocompleteSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focused },
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeholder =
|
const placeholder =
|
||||||
@@ -72,16 +200,26 @@ export function InputBar({
|
|||||||
: 'message mosaic…');
|
: 'message mosaic…');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
<Box flexDirection="column">
|
||||||
<Text bold color="green">
|
{showAutocomplete && (
|
||||||
{'❯ '}
|
<CommandAutocomplete
|
||||||
</Text>
|
commands={availableCommands}
|
||||||
<TextInput
|
selectedIndex={autocompleteIndex}
|
||||||
value={input}
|
inputValue={input}
|
||||||
onChange={setInput}
|
/>
|
||||||
onSubmit={handleSubmit}
|
)}
|
||||||
placeholder={placeholder}
|
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||||
/>
|
<Text bold color="green">
|
||||||
|
{'❯ '}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
focus={focused}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,31 @@ async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T>
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Conversation types ──
|
||||||
|
|
||||||
|
export interface ConversationInfo {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
archived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation endpoints ──
|
||||||
|
|
||||||
|
export async function createConversation(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
data: { title?: string; projectId?: string } = {},
|
||||||
|
): Promise<ConversationInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider / Model endpoints ──
|
// ── Provider / Model endpoints ──
|
||||||
|
|
||||||
export async function fetchAvailableModels(
|
export async function fetchAvailableModels(
|
||||||
|
|||||||
@@ -31,18 +31,22 @@ export function useConversations(opts: UseConversationsOptions): UseConversation
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
const headers = useCallback((): Record<string, string> => {
|
const headers = useCallback(
|
||||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
(includeContentType = true): Record<string, string> => {
|
||||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
const h: Record<string, string> = { Origin: gatewayUrl };
|
||||||
return h;
|
if (includeContentType) h['Content-Type'] = 'application/json';
|
||||||
}, [sessionCookie]);
|
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
[gatewayUrl, sessionCookie],
|
||||||
|
);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers() });
|
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = (await res.json()) as ConversationSummary[];
|
const data = (await res.json()) as ConversationSummary[];
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
@@ -93,7 +97,7 @@ export function useConversations(opts: UseConversationsOptions): UseConversation
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: headers(),
|
headers: headers(false),
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
|
|||||||
126
packages/cli/src/tui/hooks/use-input-history.test.ts
Normal file
126
packages/cli/src/tui/hooks/use-input-history.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for input history logic extracted from useInputHistory.
|
||||||
|
* We test the pure state transitions directly rather than using
|
||||||
|
* React testing utilities to avoid react-dom version conflicts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
function createHistoryState() {
|
||||||
|
let history: string[] = [];
|
||||||
|
let historyIndex = -1;
|
||||||
|
let savedInput = '';
|
||||||
|
|
||||||
|
function addToHistory(input: string): void {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
if (history[0] === input) return;
|
||||||
|
history = [input, ...history].slice(0, MAX_HISTORY);
|
||||||
|
historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateUp(currentInput: string): string | null {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
if (historyIndex === -1) {
|
||||||
|
savedInput = currentInput;
|
||||||
|
}
|
||||||
|
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||||
|
historyIndex = nextIndex;
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateDown(): string | null {
|
||||||
|
if (historyIndex <= 0) {
|
||||||
|
historyIndex = -1;
|
||||||
|
return savedInput;
|
||||||
|
}
|
||||||
|
const nextIndex = historyIndex - 1;
|
||||||
|
historyIndex = nextIndex;
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNavigation(): void {
|
||||||
|
historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryLength(): number {
|
||||||
|
return history.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useInputHistory (logic)', () => {
|
||||||
|
let h: ReturnType<typeof createHistoryState>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h = createHistoryState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds to history on submit', () => {
|
||||||
|
h.addToHistory('hello');
|
||||||
|
h.addToHistory('world');
|
||||||
|
// navigateUp should return 'world' first (most recent)
|
||||||
|
const val = h.navigateUp('');
|
||||||
|
expect(val).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add empty strings to history', () => {
|
||||||
|
h.addToHistory('');
|
||||||
|
h.addToHistory(' ');
|
||||||
|
const val = h.navigateUp('');
|
||||||
|
expect(val).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigateDown after up returns saved input', () => {
|
||||||
|
h.addToHistory('first');
|
||||||
|
const up = h.navigateUp('current');
|
||||||
|
expect(up).toBe('first');
|
||||||
|
const down = h.navigateDown();
|
||||||
|
expect(down).toBe('current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add duplicate consecutive entries', () => {
|
||||||
|
h.addToHistory('same');
|
||||||
|
h.addToHistory('same');
|
||||||
|
expect(h.getHistoryLength()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps history at MAX_HISTORY entries', () => {
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
h.addToHistory(`entry-${i}`);
|
||||||
|
}
|
||||||
|
expect(h.getHistoryLength()).toBe(50);
|
||||||
|
// Navigate to the oldest entry
|
||||||
|
let val: string | null = null;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
val = h.navigateUp('');
|
||||||
|
}
|
||||||
|
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
|
||||||
|
expect(val).toBe('entry-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigateUp returns null when history is empty', () => {
|
||||||
|
const val = h.navigateUp('something');
|
||||||
|
expect(val).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigateUp cycles through multiple entries', () => {
|
||||||
|
h.addToHistory('a');
|
||||||
|
h.addToHistory('b');
|
||||||
|
h.addToHistory('c');
|
||||||
|
expect(h.navigateUp('')).toBe('c');
|
||||||
|
expect(h.navigateUp('c')).toBe('b');
|
||||||
|
expect(h.navigateUp('b')).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetNavigation resets index to -1', () => {
|
||||||
|
h.addToHistory('test');
|
||||||
|
h.navigateUp('');
|
||||||
|
h.resetNavigation();
|
||||||
|
// After reset, navigateUp from index -1 returns most recent again
|
||||||
|
const val = h.navigateUp('');
|
||||||
|
expect(val).toBe('test');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/cli/src/tui/hooks/use-input-history.ts
Normal file
48
packages/cli/src/tui/hooks/use-input-history.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
export function useInputHistory() {
|
||||||
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||||
|
const [savedInput, setSavedInput] = useState<string>('');
|
||||||
|
|
||||||
|
const addToHistory = useCallback((input: string) => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
setHistory((prev) => {
|
||||||
|
// Avoid duplicate consecutive entries
|
||||||
|
if (prev[0] === input) return prev;
|
||||||
|
return [input, ...prev].slice(0, MAX_HISTORY);
|
||||||
|
});
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateUp = useCallback(
|
||||||
|
(currentInput: string): string | null => {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
if (historyIndex === -1) {
|
||||||
|
setSavedInput(currentInput);
|
||||||
|
}
|
||||||
|
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
},
|
||||||
|
[history, historyIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateDown = useCallback((): string | null => {
|
||||||
|
if (historyIndex <= 0) {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
return savedInput;
|
||||||
|
}
|
||||||
|
const nextIndex = historyIndex - 1;
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
}, [history, historyIndex, savedInput]);
|
||||||
|
|
||||||
|
const resetNavigation = useCallback(() => {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { addToHistory, navigateUp, navigateDown, resetNavigation };
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
SessionInfoPayload,
|
SessionInfoPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
} from '@mosaic/types';
|
} from '@mosaic/types';
|
||||||
import { commandRegistry } from '../commands/index.js';
|
import { commandRegistry } from '../commands/index.js';
|
||||||
|
|
||||||
@@ -230,6 +232,27 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
|||||||
commandRegistry.updateManifest(data.manifest);
|
commandRegistry.updateManifest(data.manifest);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('command:result', (data: SlashCommandResultPayload) => {
|
||||||
|
const prefix = data.success ? '' : 'Error: ';
|
||||||
|
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('system:reload', (data: SystemReloadPayload) => {
|
||||||
|
commandRegistry.updateManifest({
|
||||||
|
commands: data.commands,
|
||||||
|
skills: data.skills,
|
||||||
|
version: Date.now(),
|
||||||
|
});
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'system', content: data.message, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
export interface DiscordPluginConfig {
|
export interface DiscordPluginConfig {
|
||||||
@@ -86,6 +86,34 @@ export class DiscordPlugin {
|
|||||||
await this.client.destroy();
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createProjectChannel(project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<{ channelId: string } | null> {
|
||||||
|
if (!this.config.guildId) return null;
|
||||||
|
|
||||||
|
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||||
|
if (!guild) return null;
|
||||||
|
|
||||||
|
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
|
||||||
|
const channelName = `mosaic-${project.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')}`;
|
||||||
|
|
||||||
|
const channel = await guild.channels.create({
|
||||||
|
name: channelName,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
topic: project.description ?? `Mosaic project: ${project.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the channel mapping so messages route correctly
|
||||||
|
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
||||||
|
|
||||||
|
return { channelId: channel.id };
|
||||||
|
}
|
||||||
|
|
||||||
private handleDiscordMessage(message: DiscordMessage): void {
|
private handleDiscordMessage(message: DiscordMessage): void {
|
||||||
// Ignore bot messages
|
// Ignore bot messages
|
||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user