feat(gateway): PreferencesService + /preferences REST + /system Valkey override (P8-011)
- PreferencesService: platform defaults, user overrides, IMMUTABLE_KEYS enforcement - PreferencesController: GET /api/preferences, POST /api/preferences, DELETE /api/preferences/:key - PreferencesModule: global module exporting PreferencesService and SystemOverrideService - SystemOverrideService: Valkey-backed session-scoped system prompt override with 5-min TTL + renew - CommandRegistryService: register /system command (socket execution) - CommandExecutorService: handle /system command via SystemOverrideService - AgentService: inject system override before each prompt turn, renew TTL; store userId in session - ChatGateway: pass userId when creating agent sessions - PreferencesService unit tests: 11 tests covering defaults, overrides, enforcement wins, immutable key errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -24,6 +24,8 @@ 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';
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -55,6 +57,8 @@ 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 {
|
||||||
@@ -73,6 +77,8 @@ export interface AgentSession {
|
|||||||
sandboxDir: string;
|
sandboxDir: string;
|
||||||
/** Tool names available in this session, or null when all tools are available. */
|
/** Tool names available in this session, or null when all tools are available. */
|
||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -89,6 +95,12 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,6 +297,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
skillPromptAdditions: promptAdditions,
|
skillPromptAdditions: promptAdditions,
|
||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
userId: mergedOptions?.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
@@ -368,8 +381,20 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
|
let effectiveMessage = message;
|
||||||
|
if (this.systemOverride) {
|
||||||
|
const override = await this.systemOverride.get(sessionId);
|
||||||
|
if (override) {
|
||||||
|
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||||
|
await this.systemOverride.renew(sessionId);
|
||||||
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.piSession.prompt(message);
|
await session.piSession.prompt(effectiveMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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 { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -39,6 +40,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
PluginModule,
|
PluginModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
|||||||
@@ -87,10 +87,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
|
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
modelId: data.modelId,
|
modelId: data.modelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommandExecutorService {
|
export class CommandExecutorService {
|
||||||
@@ -10,6 +11,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(payload: SlashCommandPayload, _userId: string): Promise<SlashCommandResultPayload> {
|
async execute(payload: SlashCommandPayload, _userId: string): Promise<SlashCommandResultPayload> {
|
||||||
@@ -31,6 +33,8 @@ 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,
|
||||||
@@ -124,4 +128,28 @@ 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,6 +156,22 @@ 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',
|
||||||
|
|||||||
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
44
apps/gateway/src/preferences/preferences.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PreferencesService } from './preferences.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
|
||||||
|
@Controller('api/preferences')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class PreferencesController {
|
||||||
|
constructor(@Inject(PreferencesService) private readonly preferences: PreferencesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async show(@CurrentUser() user: { id: string }): Promise<Record<string, unknown>> {
|
||||||
|
return this.preferences.getEffective(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async set(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Body() body: { key: string; value: unknown },
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
return this.preferences.set(user.id, body.key, body.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':key')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async reset(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('key') key: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
return this.preferences.reset(user.id, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
12
apps/gateway/src/preferences/preferences.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PreferencesService } from './preferences.service.js';
|
||||||
|
import { PreferencesController } from './preferences.controller.js';
|
||||||
|
import { SystemOverrideService } from './system-override.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [PreferencesController],
|
||||||
|
providers: [PreferencesService, SystemOverrideService],
|
||||||
|
exports: [PreferencesService, SystemOverrideService],
|
||||||
|
})
|
||||||
|
export class PreferencesModule {}
|
||||||
167
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
167
apps/gateway/src/preferences/preferences.service.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
||||||
|
import type { Db } from '@mosaic/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mock Drizzle DB where the select chain supports:
|
||||||
|
* db.select().from().where() → resolves to `listRows`
|
||||||
|
* db.select().from().where().limit(n) → resolves to `singleRow`
|
||||||
|
*/
|
||||||
|
function makeMockDb(
|
||||||
|
listRows: Array<{ key: string; value: unknown }> = [],
|
||||||
|
singleRow: Array<{ id: string }> = [],
|
||||||
|
): Db {
|
||||||
|
const chainWithLimit = {
|
||||||
|
limit: vi.fn().mockResolvedValue(singleRow),
|
||||||
|
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||||
|
};
|
||||||
|
const selectFrom = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||||
|
};
|
||||||
|
const updateResult = {
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const deleteResult = {
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const insertResult = {
|
||||||
|
values: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockReturnValue(selectFrom),
|
||||||
|
update: vi.fn().mockReturnValue(updateResult),
|
||||||
|
delete: vi.fn().mockReturnValue(deleteResult),
|
||||||
|
insert: vi.fn().mockReturnValue(insertResult),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PreferencesService', () => {
|
||||||
|
describe('getEffective', () => {
|
||||||
|
it('returns platform defaults when user has no overrides', async () => {
|
||||||
|
const db = makeMockDb([]);
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.getEffective('user-1');
|
||||||
|
|
||||||
|
expect(result['agent.thinkingLevel']).toBe('auto');
|
||||||
|
expect(result['agent.streamingEnabled']).toBe(true);
|
||||||
|
expect(result['session.autoCompactEnabled']).toBe(true);
|
||||||
|
expect(result['session.autoCompactThreshold']).toBe(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies user overrides for mutable keys', async () => {
|
||||||
|
const db = makeMockDb([
|
||||||
|
{ key: 'agent.thinkingLevel', value: 'high' },
|
||||||
|
{ key: 'response.language', value: 'es' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.getEffective('user-1');
|
||||||
|
|
||||||
|
expect(result['agent.thinkingLevel']).toBe('high');
|
||||||
|
expect(result['response.language']).toBe('es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores user overrides for immutable keys — enforcement always wins', async () => {
|
||||||
|
const db = makeMockDb([
|
||||||
|
{ key: 'limits.maxThinkingLevel', value: 'high' },
|
||||||
|
{ key: 'limits.rateLimit', value: 9999 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.getEffective('user-1');
|
||||||
|
|
||||||
|
// Should still be null (platform default), not the user-supplied values
|
||||||
|
expect(result['limits.maxThinkingLevel']).toBeNull();
|
||||||
|
expect(result['limits.rateLimit']).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set', () => {
|
||||||
|
it('returns error when attempting to override an immutable key', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
|
||||||
|
const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('platform enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when attempting to override limits.rateLimit', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
|
||||||
|
const result = await service.set('user-1', 'limits.rateLimit', 100);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('platform enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts a mutable preference and returns success — insert path', async () => {
|
||||||
|
// singleRow=[] → no existing row → insert path
|
||||||
|
const db = makeMockDb([], []);
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts a mutable preference and returns success — update path', async () => {
|
||||||
|
// singleRow has an id → existing row → update path
|
||||||
|
const db = makeMockDb([], [{ id: 'existing-id' }]);
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('returns error when attempting to reset an immutable key', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
|
||||||
|
const result = await service.reset('user-1', 'limits.rateLimit');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('platform enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes user override and returns default value in message', async () => {
|
||||||
|
const db = makeMockDb();
|
||||||
|
const service = new PreferencesService(db);
|
||||||
|
const result = await service.reset('user-1', 'agent.thinkingLevel');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IMMUTABLE_KEYS', () => {
|
||||||
|
it('contains only the enforcement keys', () => {
|
||||||
|
expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true);
|
||||||
|
expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true);
|
||||||
|
expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PLATFORM_DEFAULTS', () => {
|
||||||
|
it('has all expected keys', () => {
|
||||||
|
const expectedKeys = [
|
||||||
|
'agent.defaultModel',
|
||||||
|
'agent.thinkingLevel',
|
||||||
|
'agent.streamingEnabled',
|
||||||
|
'response.language',
|
||||||
|
'response.codeAnnotations',
|
||||||
|
'safety.confirmDestructiveTools',
|
||||||
|
'session.autoCompactThreshold',
|
||||||
|
'session.autoCompactEnabled',
|
||||||
|
'limits.maxThinkingLevel',
|
||||||
|
'limits.rateLimit',
|
||||||
|
];
|
||||||
|
for (const key of expectedKeys) {
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/gateway/src/preferences/preferences.service.ts
Normal file
119
apps/gateway/src/preferences/preferences.service.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
||||||
|
'agent.defaultModel': null,
|
||||||
|
'agent.thinkingLevel': 'auto',
|
||||||
|
'agent.streamingEnabled': true,
|
||||||
|
'response.language': 'auto',
|
||||||
|
'response.codeAnnotations': true,
|
||||||
|
'safety.confirmDestructiveTools': true,
|
||||||
|
'session.autoCompactThreshold': 0.8,
|
||||||
|
'session.autoCompactEnabled': true,
|
||||||
|
'limits.maxThinkingLevel': null,
|
||||||
|
'limits.rateLimit': null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IMMUTABLE_KEYS = new Set<string>(['limits.maxThinkingLevel', 'limits.rateLimit']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PreferencesService {
|
||||||
|
private readonly logger = new Logger(PreferencesService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the effective preference set for a user:
|
||||||
|
* Platform defaults → user overrides (mutable keys only) → enforcements re-applied last
|
||||||
|
*/
|
||||||
|
async getEffective(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const userPrefs = await this.getUserPrefs(userId);
|
||||||
|
const result: Record<string, unknown> = { ...PLATFORM_DEFAULTS };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(userPrefs)) {
|
||||||
|
if (!IMMUTABLE_KEYS.has(key)) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply immutable keys (enforcements always win)
|
||||||
|
for (const key of IMMUTABLE_KEYS) {
|
||||||
|
result[key] = PLATFORM_DEFAULTS[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (IMMUTABLE_KEYS.has(key)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertPref(userId, key, value);
|
||||||
|
return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (IMMUTABLE_KEYS.has(key)) {
|
||||||
|
return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deletePref(userId, key);
|
||||||
|
const defaultVal = PLATFORM_DEFAULTS[key];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserPrefs(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ key: preferencesTable.key, value: preferencesTable.value })
|
||||||
|
.from(preferencesTable)
|
||||||
|
.where(eq(preferencesTable.userId, userId));
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.key] = row.value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
const existing = await this.db
|
||||||
|
.select({ id: preferencesTable.id })
|
||||||
|
.from(preferencesTable)
|
||||||
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await this.db
|
||||||
|
.update(preferencesTable)
|
||||||
|
.set({ value: value as never, updatedAt: new Date() })
|
||||||
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||||
|
} else {
|
||||||
|
await this.db.insert(preferencesTable).values({
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
value: value as never,
|
||||||
|
mutable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deletePref(userId: string, key: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.delete(preferencesTable)
|
||||||
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||||
|
this.logger.debug(`Deleted preference "${key}" for user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/gateway/src/preferences/system-override.service.ts
Normal file
33
apps/gateway/src/preferences/system-override.service.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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