- 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>
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
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}`);
|
|
}
|
|
}
|