chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
|
||||
/**
|
||||
* Build a mock Drizzle DB where the select chain supports:
|
||||
* db.select().from().where() → resolves to `listRows`
|
||||
* db.insert().values().onConflictDoUpdate() → resolves to []
|
||||
*/
|
||||
function makeMockDb(listRows: Array<{ key: string; value: unknown }> = []): Db {
|
||||
const chainWithLimit = {
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||
};
|
||||
const selectFrom = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||
};
|
||||
const deleteResult = {
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
// Single-round-trip upsert chain: insert().values().onConflictDoUpdate()
|
||||
const insertResult = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(selectFrom),
|
||||
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', async () => {
|
||||
// Single-round-trip INSERT … ON CONFLICT DO UPDATE 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"');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaicstack/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> {
|
||||
// Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE.
|
||||
// Previously this was two queries (SELECT + INSERT/UPDATE), which doubled
|
||||
// the DB round-trips and introduced a TOCTOU window under concurrent writes.
|
||||
await this.db
|
||||
.insert(preferencesTable)
|
||||
.values({
|
||||
userId,
|
||||
key,
|
||||
value: value as never,
|
||||
mutable: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [preferencesTable.userId, preferencesTable.key],
|
||||
set: {
|
||||
value: sql`excluded.value`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { SystemOverrideService } from './system-override.service.js';
|
||||
|
||||
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
||||
|
||||
describe('SystemOverrideService local tier', () => {
|
||||
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
||||
const service = new SystemOverrideService(localConfig);
|
||||
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
||||
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
||||
|
||||
await service.set('shared-session', 'first override', firstScope);
|
||||
await service.set('shared-session', 'second override', secondScope);
|
||||
|
||||
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||
|
||||
await service.clear('shared-session', firstScope);
|
||||
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
|
||||
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||
|
||||
interface OverrideFragment {
|
||||
text: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
const key = scopedSessionId(sessionId, scope);
|
||||
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||
entry.condensed = await this.condenseOverrides(
|
||||
entry.fragments.map((fragment) => fragment.text),
|
||||
);
|
||||
this.localStore.set(key, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
? (JSON.parse(existing) as OverrideFragment[])
|
||||
: [];
|
||||
|
||||
// Append new fragment
|
||||
fragments.push({ text: override, addedAt: Date.now() });
|
||||
|
||||
// Condense fragments into one coherent override
|
||||
const texts = fragments.map((f) => f.text);
|
||||
const condensed = await this.condenseOverrides(texts);
|
||||
|
||||
// Store both: fragments array and condensed result
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.setex(
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||
JSON.stringify(fragments),
|
||||
);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
await pipeline.exec();
|
||||
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (${fragments.length} fragment(s), TTL=${SYSTEM_OVERRIDE_TTL_SECONDS}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) return;
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||
return;
|
||||
}
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an array of override fragments into one coherent string.
|
||||
* If only one fragment exists, returns it as-is.
|
||||
* For multiple fragments, calls Haiku to produce a merged instruction.
|
||||
* Falls back to newline concatenation if the LLM call fails.
|
||||
*/
|
||||
async condenseOverrides(fragments: string[]): Promise<string> {
|
||||
if (fragments.length === 0) return '';
|
||||
if (fragments.length === 1) return fragments[0]!;
|
||||
|
||||
const numbered = fragments.map((f, i) => `${i + 1}. ${f}`).join('\n');
|
||||
const prompt =
|
||||
`Merge these system prompt instructions into one coherent paragraph. ` +
|
||||
`If instructions conflict, favor the most recently added (last in the list). ` +
|
||||
`Be concise — output only the merged instruction, nothing else.\n\n` +
|
||||
`Instructions (oldest first):\n${numbered}`;
|
||||
|
||||
const apiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.warn('ANTHROPIC_API_KEY not set — falling back to newline concatenation');
|
||||
return fragments.join('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Anthropic API error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
};
|
||||
|
||||
const textBlock = data.content.find((c) => c.type === 'text');
|
||||
if (!textBlock) {
|
||||
throw new Error('No text block in Anthropic response');
|
||||
}
|
||||
|
||||
return textBlock.text.trim();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Condensation LLM call failed — falling back to newline concatenation: ${String(err)}`,
|
||||
);
|
||||
return fragments.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user