Compare commits
2 Commits
bfbe9fff97
...
3fcc03379a
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fcc03379a | |||
| 96409c40bf |
@@ -20,6 +20,7 @@ import { AdminModule } from './admin/admin.module.js';
|
||||
import { CommandsModule } from './commands/commands.module.js';
|
||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||
import { GCModule } from './gc/gc.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
@@ -44,6 +45,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
PreferencesModule,
|
||||
CommandsModule,
|
||||
GCModule,
|
||||
ReloadModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { SetThinkingPayload, SlashCommandPayload } from '@mosaic/types';
|
||||
import type { SetThinkingPayload, SlashCommandPayload, SystemReloadPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
@@ -203,6 +203,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
broadcastReload(payload: SystemReloadPayload): void {
|
||||
this.server.emit('system:reload', payload);
|
||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||
}
|
||||
|
||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||
if (!client.connected) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
|
||||
@Module({
|
||||
imports: [CommandsModule],
|
||||
imports: [forwardRef(() => CommandsModule)],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
exports: [ChatGateway],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
213
apps/gateway/src/commands/command-executor-p8012.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaic/types';
|
||||
|
||||
// Minimal mock implementations
|
||||
const mockRegistry = {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: 1,
|
||||
commands: [
|
||||
{ name: 'provider', aliases: [], scope: 'agent', execution: 'hybrid', available: true },
|
||||
{ name: 'mission', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const mockAgentService = {
|
||||
getSession: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
const mockSystemOverride = {
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
renew: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSessionGC = {
|
||||
sweepOrphans: vi.fn(() => ({ orphanedSessions: 0, totalCleaned: [], duration: 0 })),
|
||||
};
|
||||
|
||||
const mockRedis = {
|
||||
set: vi.fn().mockResolvedValue('OK'),
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandExecutorService — P8-012 commands', () => {
|
||||
let service: CommandExecutorService;
|
||||
const userId = 'user-123';
|
||||
const conversationId = 'conv-456';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = buildService();
|
||||
});
|
||||
|
||||
// /provider login — missing provider name
|
||||
it('/provider login with no provider name returns usage error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider login');
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
|
||||
// /provider login anthropic — success with URL containing poll token
|
||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toContain('anthropic');
|
||||
expect(result.message).toContain('http');
|
||||
// data should contain loginUrl and pollToken
|
||||
expect(result.data).toBeDefined();
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(typeof data['loginUrl']).toBe('string');
|
||||
expect(typeof data['pollToken']).toBe('string');
|
||||
expect(data['loginUrl'] as string).toContain('anthropic');
|
||||
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
||||
// Verify Valkey was called
|
||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||
expect(key).toContain('mosaic:auth:poll:');
|
||||
const stored = JSON.parse(value) as { status: string; provider: string; userId: string };
|
||||
expect(stored.status).toBe('pending');
|
||||
expect(stored.provider).toBe('anthropic');
|
||||
expect(stored.userId).toBe(userId);
|
||||
expect(ttl).toBe(300);
|
||||
});
|
||||
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /provider');
|
||||
});
|
||||
|
||||
// /provider list
|
||||
it('/provider list returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
|
||||
// /provider logout with no name — usage error
|
||||
it('/provider logout with no name returns error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider logout');
|
||||
});
|
||||
|
||||
// /provider unknown subcommand
|
||||
it('/provider unknown subcommand returns error', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'unknown',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown subcommand');
|
||||
});
|
||||
|
||||
// /mission status
|
||||
it('/mission status returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('mission');
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
|
||||
// /mission with no args
|
||||
it('/mission with no args returns status stub', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
|
||||
// /mission set <id>
|
||||
it('/mission set <id> returns confirmation', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'mission',
|
||||
args: 'set my-mission-123',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-mission-123');
|
||||
});
|
||||
|
||||
// /agent list
|
||||
it('/agent list returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toContain('agent');
|
||||
});
|
||||
|
||||
// /agent with no args
|
||||
it('/agent with no args returns usage', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /agent');
|
||||
});
|
||||
|
||||
// /agent <id> — switch
|
||||
it('/agent <id> returns switch confirmation', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'my-agent-id',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-agent-id');
|
||||
});
|
||||
|
||||
// /prdy
|
||||
it('/prdy returns PRD wizard message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('prdy');
|
||||
expect(result.message).toContain('mosaic prdy');
|
||||
});
|
||||
|
||||
// /tools
|
||||
it('/tools returns tools stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Inject, Injectable, Logger } 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 { AgentService } from '../agent/agent.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { ReloadService } from '../reload/reload.service.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CommandExecutorService {
|
||||
@@ -14,6 +18,13 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
private readonly reloadService: ReloadService | null,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
@@ -75,6 +86,40 @@ export class CommandExecutorService {
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
return await this.handleMission(args ?? null, conversationId, userId);
|
||||
case 'prdy':
|
||||
return {
|
||||
command: 'prdy',
|
||||
success: true,
|
||||
message:
|
||||
'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.',
|
||||
conversationId,
|
||||
};
|
||||
case 'tools':
|
||||
return await this.handleTools(conversationId, userId);
|
||||
case 'reload': {
|
||||
if (!this.reloadService) {
|
||||
return {
|
||||
command: 'reload',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'ReloadService is not available.',
|
||||
};
|
||||
}
|
||||
const reloadResult = await this.reloadService.reload('command');
|
||||
this.chatGateway?.broadcastReload(reloadResult);
|
||||
return {
|
||||
command: 'reload',
|
||||
success: true,
|
||||
message: reloadResult.message,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
command,
|
||||
@@ -164,4 +209,165 @@ export class CommandExecutorService {
|
||||
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}\n\n(URL copied to clipboard)`,
|
||||
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,78 @@ export class CommandRegistryService implements OnModuleInit {
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
description: 'Switch or list available agents',
|
||||
aliases: ['a'],
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'list or <agent-id>',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
description: 'Manage LLM providers (list/login/logout)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'list | login <name> | logout <name>',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'hybrid',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'status | set <id> | list | tasks',
|
||||
},
|
||||
],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'prdy',
|
||||
description: 'Launch PRD wizard',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
description: 'List available agent tools',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
description: 'Soft-reload gateway plugins and command manifest (admin)',
|
||||
aliases: [],
|
||||
scope: 'admin',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
imports: [GCModule],
|
||||
providers: [CommandRegistryService, CommandExecutorService],
|
||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule {}
|
||||
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';
|
||||
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
20
apps/gateway/src/reload/mosaic-plugin.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface MosaicPlugin {
|
||||
/** Called when the plugin is loaded/reloaded */
|
||||
onLoad(): Promise<void>;
|
||||
|
||||
/** Called before the plugin is unloaded during reload */
|
||||
onUnload(): Promise<void>;
|
||||
|
||||
/** Plugin identifier for registry */
|
||||
readonly pluginName: string;
|
||||
}
|
||||
|
||||
export function isMosaicPlugin(obj: unknown): obj is MosaicPlugin {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
typeof (obj as MosaicPlugin).onLoad === 'function' &&
|
||||
typeof (obj as MosaicPlugin).onUnload === 'function' &&
|
||||
typeof (obj as MosaicPlugin).pluginName === 'string'
|
||||
);
|
||||
}
|
||||
22
apps/gateway/src/reload/reload.controller.ts
Normal file
22
apps/gateway/src/reload/reload.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Controller, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import type { SystemReloadPayload } from '@mosaic/types';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
|
||||
@Controller('api/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
export class ReloadController {
|
||||
constructor(
|
||||
@Inject(ReloadService) private readonly reloadService: ReloadService,
|
||||
@Inject(ChatGateway) private readonly chatGateway: ChatGateway,
|
||||
) {}
|
||||
|
||||
@Post('reload')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async triggerReload(): Promise<SystemReloadPayload> {
|
||||
const result = await this.reloadService.reload('rest');
|
||||
this.chatGateway.broadcastReload(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
14
apps/gateway/src/reload/reload.module.ts
Normal file
14
apps/gateway/src/reload/reload.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { ReloadController } from './reload.controller.js';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => CommandsModule), forwardRef(() => ChatModule)],
|
||||
controllers: [ReloadController],
|
||||
providers: [ReloadService, AdminGuard],
|
||||
exports: [ReloadService],
|
||||
})
|
||||
export class ReloadModule {}
|
||||
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
106
apps/gateway/src/reload/reload.service.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
|
||||
function createMockCommandRegistry() {
|
||||
return {
|
||||
getManifest: vi.fn().mockReturnValue({
|
||||
version: 1,
|
||||
commands: [],
|
||||
skills: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createService() {
|
||||
const registry = createMockCommandRegistry();
|
||||
const service = new ReloadService(registry as never);
|
||||
return { service, registry };
|
||||
}
|
||||
|
||||
describe('ReloadService', () => {
|
||||
it('reload() calls onUnload then onLoad for registered MosaicPlugin', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const callOrder: string[] = [];
|
||||
const mockPlugin = {
|
||||
pluginName: 'test-plugin',
|
||||
onLoad: vi.fn().mockImplementation(() => {
|
||||
callOrder.push('onLoad');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
onUnload: vi.fn().mockImplementation(() => {
|
||||
callOrder.push('onUnload');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
|
||||
service.registerPlugin('test-plugin', mockPlugin);
|
||||
const result = await service.reload('command');
|
||||
|
||||
expect(mockPlugin.onUnload).toHaveBeenCalledOnce();
|
||||
expect(mockPlugin.onLoad).toHaveBeenCalledOnce();
|
||||
expect(callOrder).toEqual(['onUnload', 'onLoad']);
|
||||
expect(result.message).toContain('test-plugin');
|
||||
});
|
||||
|
||||
it('reload() continues if one plugin throws during onUnload', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const badPlugin = {
|
||||
pluginName: 'bad-plugin',
|
||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||
onUnload: vi.fn().mockRejectedValue(new Error('unload failed')),
|
||||
};
|
||||
|
||||
service.registerPlugin('bad-plugin', badPlugin);
|
||||
const result = await service.reload('command');
|
||||
|
||||
expect(result.message).toContain('bad-plugin');
|
||||
expect(result.message).toContain('unload failed');
|
||||
});
|
||||
|
||||
it('reload() skips non-MosaicPlugin objects', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const notAPlugin = { foo: 'bar' };
|
||||
service.registerPlugin('not-a-plugin', notAPlugin);
|
||||
|
||||
// Should not throw
|
||||
const result = await service.reload('command');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).not.toContain('not-a-plugin');
|
||||
});
|
||||
|
||||
it('reload() returns SystemReloadPayload with commands, skills, providers, message', async () => {
|
||||
const { service, registry } = createService();
|
||||
registry.getManifest.mockReturnValue({
|
||||
version: 1,
|
||||
commands: [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'test cmd',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
});
|
||||
|
||||
const result = await service.reload('rest');
|
||||
|
||||
expect(result).toHaveProperty('commands');
|
||||
expect(result).toHaveProperty('skills');
|
||||
expect(result).toHaveProperty('providers');
|
||||
expect(result).toHaveProperty('message');
|
||||
expect(result.commands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('registerPlugin() logs plugin registration', () => {
|
||||
const { service } = createService();
|
||||
|
||||
// Should not throw and should register
|
||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
92
apps/gateway/src/reload/reload.service.ts
Normal file
92
apps/gateway/src/reload/reload.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationBootstrap,
|
||||
type OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import type { SystemReloadPayload } from '@mosaic/types';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { isMosaicPlugin } from './mosaic-plugin.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class ReloadService implements OnApplicationBootstrap, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(ReloadService.name);
|
||||
private readonly plugins: Map<string, unknown> = new Map();
|
||||
private shutdownHandlerAttached = false;
|
||||
|
||||
constructor(
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.shutdownHandlerAttached) {
|
||||
process.on('SIGHUP', () => {
|
||||
this.logger.log('SIGHUP received — triggering soft reload');
|
||||
this.reload('sighup').catch((err: unknown) => {
|
||||
this.logger.error(`SIGHUP reload failed: ${err}`);
|
||||
});
|
||||
});
|
||||
this.shutdownHandlerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
process.removeAllListeners('SIGHUP');
|
||||
}
|
||||
|
||||
registerPlugin(name: string, plugin: unknown): void {
|
||||
this.plugins.set(name, plugin);
|
||||
this.logger.log(`Plugin registered: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft reload — unload plugins, reload plugins, broadcast.
|
||||
* Does NOT restart the HTTP server or drop connections.
|
||||
*/
|
||||
async reload(
|
||||
trigger: 'command' | 'rest' | 'sighup' | 'file-watch',
|
||||
): Promise<SystemReloadPayload> {
|
||||
this.logger.log(`Soft reload triggered by: ${trigger}`);
|
||||
const reloaded: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. Unload all registered MosaicPlugin instances
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (isMosaicPlugin(plugin)) {
|
||||
try {
|
||||
await plugin.onUnload();
|
||||
reloaded.push(name);
|
||||
} catch (err) {
|
||||
errors.push(`${name}: unload failed — ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reload all MosaicPlugin instances
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (isMosaicPlugin(plugin)) {
|
||||
try {
|
||||
await plugin.onLoad();
|
||||
} catch (err) {
|
||||
errors.push(`${name}: load failed — ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = this.commandRegistry.getManifest();
|
||||
|
||||
const errorSuffix = errors.length > 0 ? ` Errors: ${errors.join(', ')}` : '';
|
||||
const payload: SystemReloadPayload = {
|
||||
commands: manifest.commands,
|
||||
skills: manifest.skills,
|
||||
providers: [],
|
||||
message: `Reload complete (trigger=${trigger}). Plugins reloaded: [${reloaded.join(', ')}].${errorSuffix}`,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Reload complete. Reloaded: [${reloaded.join(', ')}]. Errors: ${errors.length}`,
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
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.
|
||||
Reference in New Issue
Block a user