Compare commits
1 Commits
b649b5c987
...
bffd5883f3
| Author | SHA1 | Date | |
|---|---|---|---|
| bffd5883f3 |
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -24,8 +24,6 @@ import { createGitTools } from './tools/git-tools.js';
|
|||||||
import { createShellTools } from './tools/shell-tools.js';
|
import { createShellTools } from './tools/shell-tools.js';
|
||||||
import { createWebTools } from './tools/web-tools.js';
|
import { createWebTools } from './tools/web-tools.js';
|
||||||
import type { SessionInfoDto } from './session.dto.js';
|
import type { SessionInfoDto } from './session.dto.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
@@ -58,8 +56,6 @@ export interface AgentSessionOptions {
|
|||||||
* take precedence over config values.
|
* take precedence over config values.
|
||||||
*/
|
*/
|
||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
|
||||||
userId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSession {
|
export interface AgentSession {
|
||||||
@@ -78,8 +74,6 @@ export interface AgentSession {
|
|||||||
sandboxDir: string;
|
sandboxDir: string;
|
||||||
/** Tool names available in this session, or null when all tools are available. */
|
/** Tool names available in this session, or null when all tools are available. */
|
||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
/** User ID that owns this session, used for preference lookups. */
|
|
||||||
userId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -96,12 +90,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
@Optional()
|
|
||||||
@Inject(SystemOverrideService)
|
|
||||||
private readonly systemOverride: SystemOverrideService | null,
|
|
||||||
@Optional()
|
|
||||||
@Inject(PreferencesService)
|
|
||||||
private readonly preferencesService: PreferencesService | null,
|
|
||||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -299,7 +287,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
skillPromptAdditions: promptAdditions,
|
skillPromptAdditions: promptAdditions,
|
||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
@@ -383,20 +370,8 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
|
||||||
let effectiveMessage = message;
|
|
||||||
if (this.systemOverride) {
|
|
||||||
const override = await this.systemOverride.get(sessionId);
|
|
||||||
if (override) {
|
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
|
||||||
await this.systemOverride.renew(sessionId);
|
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.piSession.prompt(effectiveMessage);
|
await session.piSession.prompt(message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { PluginModule } from './plugin/plugin.module.js';
|
|||||||
import { McpModule } from './mcp/mcp.module.js';
|
import { McpModule } from './mcp/mcp.module.js';
|
||||||
import { AdminModule } from './admin/admin.module.js';
|
import { AdminModule } from './admin/admin.module.js';
|
||||||
import { CommandsModule } from './commands/commands.module.js';
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
PluginModule,
|
PluginModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
PreferencesModule,
|
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -87,12 +87,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
modelId: data.modelId,
|
modelId: data.modelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
userId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||||||
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 { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,7 +11,6 @@ export class CommandExecutorService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -35,8 +33,6 @@ export class CommandExecutorService {
|
|||||||
return await this.handleModel(args ?? null, conversationId);
|
return await this.handleModel(args ?? null, conversationId);
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return await this.handleThinking(args ?? null, conversationId);
|
return await this.handleThinking(args ?? null, conversationId);
|
||||||
case 'system':
|
|
||||||
return await this.handleSystem(args ?? null, conversationId);
|
|
||||||
case 'new':
|
case 'new':
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
@@ -140,28 +136,4 @@ export class CommandExecutorService {
|
|||||||
message: `Thinking level set to "${level}".`,
|
message: `Thinking level set to "${level}".`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSystem(
|
|
||||||
args: string | null,
|
|
||||||
conversationId: string,
|
|
||||||
): Promise<SlashCommandResultPayload> {
|
|
||||||
if (!args || args.trim().length === 0) {
|
|
||||||
// Clear the override when called with no args
|
|
||||||
await this.systemOverride.clear(conversationId);
|
|
||||||
return {
|
|
||||||
command: 'system',
|
|
||||||
conversationId,
|
|
||||||
success: true,
|
|
||||||
message: 'Session system prompt override cleared.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.systemOverride.set(conversationId, args.trim());
|
|
||||||
return {
|
|
||||||
command: 'system',
|
|
||||||
conversationId,
|
|
||||||
success: true,
|
|
||||||
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,22 +156,6 @@ export class CommandRegistryService implements OnModuleInit {
|
|||||||
execution: 'rest',
|
execution: 'rest',
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'system',
|
|
||||||
description: 'Set session-scoped system prompt override',
|
|
||||||
aliases: [],
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'override',
|
|
||||||
type: 'string',
|
|
||||||
optional: false,
|
|
||||||
description: 'System prompt text to inject for this session',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'status',
|
name: 'status',
|
||||||
description: 'Show session and connection status',
|
description: 'Show session and connection status',
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PreferencesService } from './preferences.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
|
|
||||||
@Controller('api/preferences')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class PreferencesController {
|
|
||||||
constructor(@Inject(PreferencesService) private readonly preferences: PreferencesService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async show(@CurrentUser() user: { id: string }): Promise<Record<string, unknown>> {
|
|
||||||
return this.preferences.getEffective(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async set(
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
@Body() body: { key: string; value: unknown },
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
return this.preferences.set(user.id, body.key, body.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':key')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async reset(
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
@Param('key') key: string,
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
return this.preferences.reset(user.id, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { PreferencesService } from './preferences.service.js';
|
|
||||||
import { PreferencesController } from './preferences.controller.js';
|
|
||||||
import { SystemOverrideService } from './system-override.service.js';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
controllers: [PreferencesController],
|
|
||||||
providers: [PreferencesService, SystemOverrideService],
|
|
||||||
exports: [PreferencesService, SystemOverrideService],
|
|
||||||
})
|
|
||||||
export class PreferencesModule {}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
|
||||||
import type { Db } from '@mosaic/db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a mock Drizzle DB where the select chain supports:
|
|
||||||
* db.select().from().where() → resolves to `listRows`
|
|
||||||
* db.select().from().where().limit(n) → resolves to `singleRow`
|
|
||||||
*/
|
|
||||||
function makeMockDb(
|
|
||||||
listRows: Array<{ key: string; value: unknown }> = [],
|
|
||||||
singleRow: Array<{ id: string }> = [],
|
|
||||||
): Db {
|
|
||||||
const chainWithLimit = {
|
|
||||||
limit: vi.fn().mockResolvedValue(singleRow),
|
|
||||||
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
|
||||||
};
|
|
||||||
const selectFrom = {
|
|
||||||
from: vi.fn().mockReturnThis(),
|
|
||||||
where: vi.fn().mockReturnValue(chainWithLimit),
|
|
||||||
};
|
|
||||||
const updateResult = {
|
|
||||||
set: vi.fn().mockReturnThis(),
|
|
||||||
where: vi.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
const deleteResult = {
|
|
||||||
where: vi.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
const insertResult = {
|
|
||||||
values: vi.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
select: vi.fn().mockReturnValue(selectFrom),
|
|
||||||
update: vi.fn().mockReturnValue(updateResult),
|
|
||||||
delete: vi.fn().mockReturnValue(deleteResult),
|
|
||||||
insert: vi.fn().mockReturnValue(insertResult),
|
|
||||||
} as unknown as Db;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PreferencesService', () => {
|
|
||||||
describe('getEffective', () => {
|
|
||||||
it('returns platform defaults when user has no overrides', async () => {
|
|
||||||
const db = makeMockDb([]);
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
const result = await service.getEffective('user-1');
|
|
||||||
|
|
||||||
expect(result['agent.thinkingLevel']).toBe('auto');
|
|
||||||
expect(result['agent.streamingEnabled']).toBe(true);
|
|
||||||
expect(result['session.autoCompactEnabled']).toBe(true);
|
|
||||||
expect(result['session.autoCompactThreshold']).toBe(0.8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies user overrides for mutable keys', async () => {
|
|
||||||
const db = makeMockDb([
|
|
||||||
{ key: 'agent.thinkingLevel', value: 'high' },
|
|
||||||
{ key: 'response.language', value: 'es' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
const result = await service.getEffective('user-1');
|
|
||||||
|
|
||||||
expect(result['agent.thinkingLevel']).toBe('high');
|
|
||||||
expect(result['response.language']).toBe('es');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores user overrides for immutable keys — enforcement always wins', async () => {
|
|
||||||
const db = makeMockDb([
|
|
||||||
{ key: 'limits.maxThinkingLevel', value: 'high' },
|
|
||||||
{ key: 'limits.rateLimit', value: 9999 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
const result = await service.getEffective('user-1');
|
|
||||||
|
|
||||||
// Should still be null (platform default), not the user-supplied values
|
|
||||||
expect(result['limits.maxThinkingLevel']).toBeNull();
|
|
||||||
expect(result['limits.rateLimit']).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('set', () => {
|
|
||||||
it('returns error when attempting to override an immutable key', async () => {
|
|
||||||
const db = makeMockDb();
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
|
|
||||||
const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toContain('platform enforcement');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error when attempting to override limits.rateLimit', async () => {
|
|
||||||
const db = makeMockDb();
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
|
|
||||||
const result = await service.set('user-1', 'limits.rateLimit', 100);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toContain('platform enforcement');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('upserts a mutable preference and returns success — insert path', async () => {
|
|
||||||
// singleRow=[] → no existing row → insert path
|
|
||||||
const db = makeMockDb([], []);
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('upserts a mutable preference and returns success — update path', async () => {
|
|
||||||
// singleRow has an id → existing row → update path
|
|
||||||
const db = makeMockDb([], [{ id: 'existing-id' }]);
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reset', () => {
|
|
||||||
it('returns error when attempting to reset an immutable key', async () => {
|
|
||||||
const db = makeMockDb();
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
|
|
||||||
const result = await service.reset('user-1', 'limits.rateLimit');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toContain('platform enforcement');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes user override and returns default value in message', async () => {
|
|
||||||
const db = makeMockDb();
|
|
||||||
const service = new PreferencesService(db);
|
|
||||||
const result = await service.reset('user-1', 'agent.thinkingLevel');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('IMMUTABLE_KEYS', () => {
|
|
||||||
it('contains only the enforcement keys', () => {
|
|
||||||
expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true);
|
|
||||||
expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true);
|
|
||||||
expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PLATFORM_DEFAULTS', () => {
|
|
||||||
it('has all expected keys', () => {
|
|
||||||
const expectedKeys = [
|
|
||||||
'agent.defaultModel',
|
|
||||||
'agent.thinkingLevel',
|
|
||||||
'agent.streamingEnabled',
|
|
||||||
'response.language',
|
|
||||||
'response.codeAnnotations',
|
|
||||||
'safety.confirmDestructiveTools',
|
|
||||||
'session.autoCompactThreshold',
|
|
||||||
'session.autoCompactEnabled',
|
|
||||||
'limits.maxThinkingLevel',
|
|
||||||
'limits.rateLimit',
|
|
||||||
];
|
|
||||||
for (const key of expectedKeys) {
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
|
|
||||||
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
|
||||||
'agent.defaultModel': null,
|
|
||||||
'agent.thinkingLevel': 'auto',
|
|
||||||
'agent.streamingEnabled': true,
|
|
||||||
'response.language': 'auto',
|
|
||||||
'response.codeAnnotations': true,
|
|
||||||
'safety.confirmDestructiveTools': true,
|
|
||||||
'session.autoCompactThreshold': 0.8,
|
|
||||||
'session.autoCompactEnabled': true,
|
|
||||||
'limits.maxThinkingLevel': null,
|
|
||||||
'limits.rateLimit': null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IMMUTABLE_KEYS = new Set<string>(['limits.maxThinkingLevel', 'limits.rateLimit']);
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PreferencesService {
|
|
||||||
private readonly logger = new Logger(PreferencesService.name);
|
|
||||||
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective preference set for a user:
|
|
||||||
* Platform defaults → user overrides (mutable keys only) → enforcements re-applied last
|
|
||||||
*/
|
|
||||||
async getEffective(userId: string): Promise<Record<string, unknown>> {
|
|
||||||
const userPrefs = await this.getUserPrefs(userId);
|
|
||||||
const result: Record<string, unknown> = { ...PLATFORM_DEFAULTS };
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(userPrefs)) {
|
|
||||||
if (!IMMUTABLE_KEYS.has(key)) {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply immutable keys (enforcements always win)
|
|
||||||
for (const key of IMMUTABLE_KEYS) {
|
|
||||||
result[key] = PLATFORM_DEFAULTS[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async set(
|
|
||||||
userId: string,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
if (IMMUTABLE_KEYS.has(key)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.upsertPref(userId, key, value);
|
|
||||||
return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
if (IMMUTABLE_KEYS.has(key)) {
|
|
||||||
return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.deletePref(userId, key);
|
|
||||||
const defaultVal = PLATFORM_DEFAULTS[key];
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUserPrefs(userId: string): Promise<Record<string, unknown>> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ key: preferencesTable.key, value: preferencesTable.value })
|
|
||||||
.from(preferencesTable)
|
|
||||||
.where(eq(preferencesTable.userId, userId));
|
|
||||||
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
result[row.key] = row.value;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
|
||||||
const existing = await this.db
|
|
||||||
.select({ id: preferencesTable.id })
|
|
||||||
.from(preferencesTable)
|
|
||||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
await this.db
|
|
||||||
.update(preferencesTable)
|
|
||||||
.set({ value: value as never, updatedAt: new Date() })
|
|
||||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
|
||||||
} else {
|
|
||||||
await this.db.insert(preferencesTable).values({
|
|
||||||
userId,
|
|
||||||
key,
|
|
||||||
value: value as never,
|
|
||||||
mutable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async deletePref(userId: string, key: string): Promise<void> {
|
|
||||||
await this.db
|
|
||||||
.delete(preferencesTable)
|
|
||||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
|
||||||
this.logger.debug(`Deleted preference "${key}" for user ${userId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { createQueue, type QueueHandle } from '@mosaic/queue';
|
|
||||||
|
|
||||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
|
||||||
const TTL_SECONDS = 5 * 60; // 5 minutes, renewed on each turn
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SystemOverrideService {
|
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
|
||||||
private readonly handle: QueueHandle;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.handle = createQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
async set(sessionId: string, override: string): Promise<void> {
|
|
||||||
await this.handle.redis.setex(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS, override);
|
|
||||||
this.logger.debug(`Set system override for session ${sessionId} (TTL=${TTL_SECONDS}s)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(sessionId: string): Promise<string | null> {
|
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async renew(sessionId: string): Promise<void> {
|
|
||||||
await this.handle.redis.expire(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(sessionId: string): Promise<void> {
|
|
||||||
await this.handle.redis.del(SESSION_SYSTEM_KEY(sessionId));
|
|
||||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user